diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0c3b776..0e40eda 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -5,31 +5,10 @@ Thanks for contributing to Freenary. ## Before You Start - Read [`CONTEXT.md`](../CONTEXT.md) — Freenary is an open-source personal finance and wealth-management platform. Keep changes aligned with that vision. -- Read [Contribute](../apps/fumadocs/content/docs/next/contribute.mdx) in the documentation for the full workflow, and [`docs/engineering`](../docs/engineering) for how the internals work. +- Set up from [Contribute](../apps/fumadocs/content/docs/next/contribute.mdx) in the documentation: it holds the first-run steps and both ways to run the code. This file holds the rules a pull request follows. [`docs/engineering`](../docs/engineering) covers how the internals work. - Keep changes scoped to a clear user-facing improvement, bug fix, or refactor. - Follow the code standards in [`AGENTS.md`](../AGENTS.md) — the repo uses Ultracite (Oxlint + Oxfmt) with strict, auto-fixable rules. -## Local Setup - -Containerised stack (needs Docker and a free port 80, applies the migrations for you): - -```bash -bun install -bun run dev:up # PostgreSQL, migrations, API server, web app, docs site -``` - -Local stack, with the applications on your host: - -```bash -bun install -cp .env.example .env # the Postgres container reads the root .env -bun run db:start # starts the Postgres container -bun run db:push # applies the Prisma schema -bun run dev # web on 3001, server on 3000, docs on 4000 -``` - -There is no seed script. Create an account through the sign-in screen. `bun run dev:up` configures no email provider, so sign-up returns a session at once and asks for no code — and so does the `bun run dev` path above. To work on the one-time-code flows, start the stack with `bun run dev:mail` instead: it adds a Mailpit inbox and points the API server at it, so the sign-up code, the verification code and the password reset all land in that inbox. See [Contribute](../apps/fumadocs/content/docs/next/contribute.mdx) for the details, and [Configuration](../apps/fumadocs/content/docs/next/reference/configuration.mdx) for every environment variable. - ## Branch Naming Use a clear, descriptive branch name that reflects the change. @@ -44,19 +23,21 @@ Avoid vague names like `test`, `misc`, or `changes`. ## Before Opening a PR -Run the same checks that CI runs, in this order: +Run the same commands as CI's `ci` job, in this order: ```bash +bun install --frozen-lockfile bun run check # Oxlint + Oxfmt bun run check-types # TypeScript bun run check:fsd # Feature-Sliced Design layers and public APIs bun run docs:check # documentation gate +bun run env:check # .env.example and the configuration reference match packages/env bun run build # every app ``` Most lint/format issues are auto-fixable with `bun run fix`. -CI runs one more job that no root script covers: it parses every Compose file with `docker compose config --quiet`, and it asserts that `docker-compose.yml` still refuses an empty `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. Reproduce it before you change either file. +CI's `compose` job has no root script. It parses `docker-compose.dev.yml` (alone and with `docker-compose.mail.yml`), `docker-compose.proxy.yml`, `docker-compose.yml`, `deploy/docker-compose.yml` and `deploy/coolify.yml` with `docker compose config --quiet`. It also asserts that `docker-compose.yml` refuses an empty `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`, and that `deploy/docker-compose.yml` refuses an empty `FREENARY_URL`, `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. Reproduce it from [`.github/workflows/ci.yml`](workflows/ci.yml) before you change any of these files. CI runs no tests, and there is no root `test` script. Run the test files your change touches by hand with `bun test `. @@ -70,7 +51,7 @@ If your change alters documented behavior — a screen, an API route, an environ Follow the [pull request template](pull_request_template.md). Each pull request should: -- open against `dev` — `main` holds released code, and `dev` is the branch a release is cut from +- open against `dev`; `main` holds released code, and a release is cut from `main` after `dev` merges into it - explain the user-visible change - stay focused on a single topic when possible - include screenshots or screen recordings for new UI or behavior changes @@ -94,4 +75,4 @@ Do not paste code from a source whose license forbids it, and do not add a depen ## Release Process -Releases are maintainer-managed: a maintainer merges `dev` into `main` and runs the `Release` workflow, which creates the tag, the images and the GitHub release. Do not tag, and do not include a version bump in a normal contribution unless a maintainer asks for one. See [Contribute](../apps/fumadocs/content/docs/next/contribute.mdx). +Releases are maintainer-managed: a maintainer merges a docs snapshot (`cd apps/fumadocs && bun run docs:snapshot X.Y.Z`) into `dev`, merges `dev` into `main`, and runs the `Release` workflow, which creates the tag, the images and the GitHub release. Do not tag, and do not include a version bump in a normal contribution unless a maintainer asks for one. See [`docs/engineering/releasing.md`](../docs/engineering/releasing.md). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 26352b9..58a3988 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,7 +2,7 @@ [summary]: #summary -Describe the change in one paragraph. +At most four sentences: what changed, and what a reviewer should look at first. # Motivation @@ -24,4 +24,6 @@ How have similar problems been solved elsewhere — what worked, what didn't? # Notes -Flag anything reviewers should know: migration steps, risks, or follow-up work. +[notes]: #notes + +One line per fact: `Closes #n`; the visual change, or "no visual change"; the tests added; the `apps/fumadocs` pages updated, or "none" and why; any migration, breaking change or merge-order constraint. diff --git a/AGENTS.md b/AGENTS.md index 7c7a513..c07e8f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,11 @@ bun run dev:up # PostgreSQL, migrations, API server, web app, docs site `dev:up` runs `docker-compose.dev.yml` under a per-worktree Compose project and prints the URLs it serves. No dev service publishes a host port: one shared Traefik on port 80 routes plain-HTTP hostnames (`web..freenary.localhost`, `server..freenary.localhost`, `docs..freenary.localhost`) to the containers, and the slug comes from the git branch. `scripts/dev.ts` reuses a Traefik already on the `devproxy` network, or starts `docker-compose.proxy.yml`; the self-hosting `docker-compose.yml` never involves it. Use Chrome or Firefox, which treat `*.localhost` as a secure context. Without Docker for the apps, use the local path instead: ```bash -bun run db:start # PostgreSQL container from docker-compose.yml (needs the root .env: cp .env.example .env) -bun run db:push # apply the Prisma schema -bun run dev # web 3001, server 3000, docs 4000 +cp .env.example .env # POSTGRES_PASSWORD and BETTER_AUTH_SECRET for docker-compose.yml +echo 'DATABASE_URL=postgresql://postgres:password@localhost:5432/freenary' > apps/server/.env +docker compose up -d --wait postgres +(cd packages/db && bun run db:migrate:deploy) # the Prisma CLI reads DATABASE_URL from apps/server/.env +bun run dev # web 3001, server 3000, docs 4000 ``` The dev stack needs no `.env`: the `server` service declares `env_file` with `required: false`, so an optional root `.env` reaches the container line by line, and `environment` carries only the six values the stack derives from the worktree — `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN`, `AUTH_COOKIE_DOMAIN` and `NODE_ENV` (Compose gives `environment` precedence over `env_file`). It configures no email provider, so the one-time-code flows are off. `bun run dev:mail` layers `docker-compose.mail.yml` over it: that file starts `axllent/mailpit:v1.28` and points the server at it with `EMAIL_PROVIDER=smtp`, `SMTP_HOST=freenary-mailpit`, `SMTP_PORT=1025` and `EMAIL_FROM=Freenary `, and the inbox is a web page at `http://mail..freenary.localhost`. `bun run dev:reset` destroys the volumes and starts over. The `bootstrap` service runs `prisma migrate deploy` before the API server starts. @@ -79,6 +81,8 @@ Pure refactors, internal helpers, and dependency bumps that change no documented **Document what is true, and only what is true.** Read the code before writing the page, quote real names, and never describe a screen, flag, or endpoint that does not exist. Write plain, short, second-person prose; the full writing rules are in [`apps/fumadocs/AGENTS.md`](apps/fumadocs/AGENTS.md). +**A page is not evidence.** Existing documentation, including this file, can be stale or machine-written, so verify every technical claim against code, tests, schemas, command output or an approved specification before you repeat it. Never grow an existing page by paraphrasing it: update only the pages the change affects, keep accurate human-written text as it is, and delete what is obsolete or redundant. Never state a reason behind a design choice that the code does not show; ask a maintainer instead. + **Verify it.** Two gates, and neither one alone is enough: ```bash @@ -114,14 +118,14 @@ The rules live next to the code they govern: [`apps/web/AGENTS.md`](apps/web/AGE ## Branches and Releases -Pull requests target `dev`, the integration branch; `main` holds released code, and a push to either branch publishes the `ghcr.io/suiramdev/freenary-server` and `ghcr.io/suiramdev/freenary-web` images under that branch name. A maintainer releases by merging a docs snapshot (`bun run docs:snapshot X.Y.Z`) into `dev`, merging `dev` into `main`, and running the `Release` workflow, which refuses a stable version without its snapshot, then creates the `vX.Y.Z` tag, the versioned images and the GitHub release. The web image carries its version from the build argument `FREENARY_VERSION`, which is what its account-menu documentation link points at. Contributors never tag and never bump a version — see [Contribute](apps/fumadocs/content/docs/next/contribute.mdx). +Pull requests target `dev`, the integration branch; `main` holds released code, and a push to either branch publishes the `ghcr.io/suiramdev/freenary-server` and `ghcr.io/suiramdev/freenary-web` images under that branch name. A maintainer releases by merging a docs snapshot (`cd apps/fumadocs && bun run docs:snapshot X.Y.Z`) into `dev`, merging `dev` into `main`, and running the `Release` workflow, which refuses a stable version without its snapshot, then creates the `vX.Y.Z` tag, the versioned images and the GitHub release. The web image carries its version from the build argument `FREENARY_VERSION`, which is what its account-menu documentation link points at. Contributors never tag and never bump a version — see [Contribute](apps/fumadocs/content/docs/next/contribute.mdx). ## Pull Request Descriptions: Complete, Then Brief - **Reviewers skim.** A description they have to scroll does not get read, so keep the whole body under ~400 words. Fill every section; pad none. - **Summary:** at most four sentences — what changed, and what to look at first. - **Motivation, Drawbacks, Prior art:** at most four short bullets each. Drawbacks and Prior art carry the honest costs and the rejected alternatives, not a sales pitch. -- **Notes:** one line per fact — `Closes #n`, the visual change (or explicitly none), the tests added, the `apps/fumadocs` pages updated (or explicitly none, and why), the short review summary `CONTRIBUTING.md` asks for, and any migration, breaking change, or merge-order constraint. +- **Notes:** one line per fact — `Closes #n`, the visual change (or explicitly none), the tests added, the `apps/fumadocs` pages updated (or explicitly none, and why), and any migration, breaking change, or merge-order constraint. - Don't restate the issue, list changed files, narrate the implementation, or paste command output — reviewers open the diff and the linked issue for that. - Evidence a reviewer may want but need not read (verification logs, benchmark runs) belongs in a PR comment, not the description. - A section that needs a table or a code block is a smell: that detail belongs in the issue, the code, or a comment. diff --git a/CONTEXT.md b/CONTEXT.md index 9849338..b9703a9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,6 +34,8 @@ Areas are perspectives on one shared model — never independent mini-applicatio Terms defined here are canonical; do not drift to synonyms. +The shipped app and the docs word some of them differently: a **Connection** is a "bank connection", an **Account** below is a "bank account" there, "account" alone means the sign-in account, a **Recurring expense** is a "recurring payment", and a **Budget period** is a "period". + - **Financial model** — the single internal representation of a user's financial life that every area reads from. - **Connection** — the external institution/provider link through which data is synchronized (e.g. a bank connected via Powens). - **Account** — a container of holdings within a connection or created manually (bank account, brokerage account, life-insurance contract, crypto account). Hierarchy: Connection → Account → Holding / Transaction. diff --git a/README.md b/README.md index 3bc7562..ce370b1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The full documentation lives at [freenary.dev](https://freenary.dev) (source in - [Use Freenary](https://freenary.dev/docs/use/budget): budget, bank connections, assistant, account - [Run your instance](https://freenary.dev/docs/run/email): email, sign-in, updates, backups, troubleshooting - [Reference](https://freenary.dev/docs/reference/configuration): configuration, bank providers, reverse proxy, API -- [Contribute](https://freenary.dev/docs/contribute): run the code, run the checks, open a pull request +- [Contribute](apps/fumadocs/content/docs/next/contribute.mdx): run the code, run the checks, open a pull request ## Development @@ -29,22 +29,7 @@ bun install bun run dev:up ``` -`dev:up` runs everything in Docker at `http://web..freenary.localhost`. Without Docker for the apps: - -```bash -cp .env.example .env -bun run db:start -bun run db:push -bun run dev # web 3001, server 3000, docs 4000 -``` - -Run the checks before opening a pull request: - -```bash -bun run check # lint and format -bun run check-types # TypeScript -bun run build # every app -``` +`dev:up` runs everything in Docker at `http://web..freenary.localhost`. [Contribute](apps/fumadocs/content/docs/next/contribute.mdx) covers the local setup without Docker and the checks to run before a pull request. ## Contributing diff --git a/apps/fumadocs/AGENTS.md b/apps/fumadocs/AGENTS.md index c6b13e7..327bb7f 100644 --- a/apps/fumadocs/AGENTS.md +++ b/apps/fumadocs/AGENTS.md @@ -46,11 +46,11 @@ Every page is served under a version segment: `/docs/next/get-started/install`, - **Author in `content/docs/next`.** A change edits that folder alone. `content/docs/` is frozen: only a correction to that release touches it. - **A folder under `content/docs` is a version.** Its `meta.json` carries `"root": true` and a `title` equal to the folder name. `root: true` is what scopes the sidebar to one version and makes Fumadocs render the version dropdown (`getLayoutTabs`, `TreeContextProvider`). `docs:check` fails a folder that breaks either rule. - **A docs link never names a version.** Pages write `/docs/use/budget`; the `a` and `Card` wrappers in `src/shared/ui/mdx.tsx` prepend the version of the page being read, and `getLLMText` does the same for the `.md` output. `docs:check` fails an authored link that names a version. -- **A repository link names the branch, and the snapshot pins it.** Pages write `https://github.com/suiramdev/freenary/blob/main/…`; the snapshot rewrites each one to `blob/v/` in the copy, because that link cannot resolve at render time. `docs:check` fails a `blob/main` link inside a frozen version. +- **A repository link names the branch, and the snapshot pins it.** Pages write `https://github.com/suiramdev/freenary/blob/dev/…`, the branch that holds the unreleased code; the snapshot rewrites each one to `blob/v/` in the copy, because that link cannot resolve at render time. `docs:check` fails a `blob/dev` link inside a frozen version. The branch is `gitConfig.branch` in `src/shared/config/site.ts`. - **`/docs/*` with no version redirects** (307) to the newest release. `unversionedDocsRedirect` in `src/app/start.ts` owns it. The newest release is `newestRelease()` in `src/shared/content/source.ts`: the highest `X.Y` folder, or `next` while no release exists. - **A moved page keeps its old URL.** `src/shared/config/moved-pages.ts` lists `[before, after]` slug pairs. When a version has no page at the requested slug, `findMovedPage` looks up the pair in both directions and redirects to the counterpart that exists in that same version: `/docs/next/quickstart` lands on `get-started/install`, and `/docs/0.1/get-started/install` lands on `quickstart`. `loadDoc` and the `.md` route both use it. When you move or delete a page, add a pair. `docs:check` fails a pair whose `after` is missing from `next`, and a page of the newest release that is gone from `next` with no pair. - **The language rules apply to `next` alone**; the structure rules apply to every version. `check-docs.ts` skips `checkAuthoredLanguage` outside `next`. -- **A release needs its snapshot first, and it snapshots last.** A maintainer runs `bun run docs:snapshot X.Y.Z` in a pull request to `dev`; the `tag` job of `.github/workflows/release.yml` refuses a stable version whose `content/docs/X.Y` folder is missing (the `main and dev` ruleset admits no workflow push). The folder freezes at that merge, so a documentation change merged between it and the release reaches `next` alone and no gate reports it. The script derives the `X.Y` folder from the release version and pins the three moving forms `scripts/moving-refs.ts` holds: a repository link and a `raw.githubusercontent.com` download URL take `v`, and a `FREENARY_VERSION=main` line takes `X.Y`. `docs:check` reads the same table, so it fails a frozen page that still carries any of them. It stages the copy outside `content/docs` and rewrites the root `meta.json` on every run, so a repeat run finishes an interrupted one; a pre-release snapshots nothing. +- **A release needs its snapshot first, and it snapshots last.** A maintainer runs `bun run docs:snapshot X.Y.Z` in a pull request to `dev`; the `tag` job of `.github/workflows/release.yml` refuses a stable version whose `content/docs/X.Y` folder is missing (the `main and dev` ruleset admits no workflow push). The folder freezes at that merge, so a documentation change merged between it and the release reaches `next` alone and no gate reports it. The script derives the `X.Y` folder from the release version and pins the four moving forms `scripts/moving-refs.ts` holds: a repository link and a `raw.githubusercontent.com` download URL take `v`, a ``Branch: `dev` `` deploy step takes `main` (`gitConfig.releaseBranch`), and a `FREENARY_VERSION=dev` line takes `X.Y`. `docs:check` reads the same table, so it fails a frozen page that still carries any of them. It stages the copy outside `content/docs` and rewrites the root `meta.json` on every run, so a repeat run finishes an interrupted one; a pre-release snapshots nothing. - **Search, the AI panel and the LLM endpoints are scoped.** `/api/search` tags each record with its version and the default dialog passes `defaultTag` — the version of the pathname, or the newest release off the docs tree, from the root loader in `src/app/routes/__root.tsx`. `/api/chat` builds one index per version and honours the request body's version only when `listVersions()` holds it. `/llms.txt` and `/llms-full.txt` serve the newest release alone. ## Content structure @@ -76,6 +76,7 @@ contribute.mdx clone, run, checks, pull request - Second person, present tense, active voice, natural tone. Contractions and `-ing` forms are fine. - Short sentences and paragraphs. Start with what the reader wants to do, then how. Explain why a step exists in one clause. +- No filler or sales words ("note that", "in summary", "seamless", "robust", "simply run"). `docs:check` warns on each (`no-filler`); the list is `FILLER_PHRASES` in `scripts/docs-rules.ts`. - One term per concept: **instance**, **operator** (the first account), **setup screen** (`/setup`), **your domain** and **`FREENARY_URL`**, **hosting panel**, **bank provider**, **bank connection**, **sync**, **assistant**. Avoid "the stack" and "origin" on beginner pages. - Quote a UI label only when the reader has to find or press it. Never list every label, button or option. - Code blocks are copy-paste correct and use `freenary.example.com` as the placeholder. Check every command and variable against the code. @@ -87,7 +88,7 @@ contribute.mdx clone, run, checks, pull request ```bash bun run docs:check # from apps/fumadocs, or at the root -bun run docs:check --strict # warnings fail too +bun run docs:check --strict # warnings fail too; from apps/fumadocs only, turbo rejects the flag at the root bun run build # frontmatter schema, MDX compile, fence languages, prerender ``` @@ -103,9 +104,9 @@ Then `bun run dev` and open every page you changed. A green gate proves the page | A page missing from its folder's `meta.json`, or a `meta.json` entry with no page | Passes | Fails | | A version folder without `"root": true`, or with a stale title | Passes, drops out of the dropdown | Fails | | A moved page with no pair in `moved-pages.ts`, or a pair pointing nowhere | Passes | Fails | -| A `blob/main` repository link in a frozen version | Passes | Fails (releases only) | +| A `blob/dev` repository link in a frozen version | Passes | Fails (releases only) | | An operator code block in `use/` | Passes | Fails | -| An environment variable in `use/`, roadmap language, a sentence over 35 words | Passes | Warns (`next` only) | +| An environment variable in `use/`, roadmap language, filler or a sales word (`no-filler`), a sentence over 35 words | Passes | Warns (`next` only) | `scripts/check-docs.ts` reads the component list from `getMDXComponents()` and the icon list from lucide's own record, so neither can drift from the site. `scripts/docs-rules.ts` holds the fence languages and the phrase lists. A page switches a rule off for itself with `{/* docs-check disable: no-roadmap */}`. diff --git a/apps/fumadocs/content/docs/next/contribute.mdx b/apps/fumadocs/content/docs/next/contribute.mdx index a691105..8398c64 100644 --- a/apps/fumadocs/content/docs/next/contribute.mdx +++ b/apps/fumadocs/content/docs/next/contribute.mdx @@ -1,35 +1,48 @@ --- title: Contribute -description: Clone Freenary, run it locally, run the checks CI runs, and open a pull request against dev. +description: Run Freenary from source, pass the same checks as CI, and open a pull request. icon: GitPullRequest --- -Freenary is open source under the AGPL-3.0-or-later, and contributions are welcome. This page gets you from a clone to a pull request. How the code works lives in the repository, under [`docs/engineering/`](https://github.com/suiramdev/freenary/tree/main/docs/engineering). +Run the code on your machine, pass the checks CI runs, and open a pull request against `dev`. -## Set up +## Prerequisites -You need [Bun](https://bun.sh) `1.3.14` (pinned in `package.json`) and Docker. +- **Bun.** The repository pins `bun@1.3.14` in `package.json`, and CI installs the latest Bun. +- **Node.js 20.19+, 22.12+ or 24+.** `bun install` runs Prisma's install script, which refuses other Node versions. +- **Docker with Compose v2.** + +Then clone the repository and install: ```bash git clone https://github.com/suiramdev/freenary.git cd freenary bun install -bun run build:data ``` -`bun install` also generates the Prisma client. `bun run build:data` downloads the merchant dictionary once; without it, merchant names aren't matched and fewer transactions get a category. - -## Run it - -**With Docker (recommended).** Needs port `80` free: +## Run it in Docker ```bash bun run dev:up ``` -This builds the images, applies migrations and starts PostgreSQL, the API server, the web app and these docs, each on its own hostname. The banner prints them, for example `http://web..freenary.localhost`. Use Chrome or Firefox. Each git worktree gets its own containers, database and hostnames. `bun run dev:mail` adds a Mailpit inbox for testing sign-up codes, and `bun run dev:reset` wipes the database. +It starts PostgreSQL, applies the migrations, and runs the API server, the web app and the documentation site with live reload. It prints the addresses it serves: + +- `http://web..freenary.localhost` +- `http://server..freenary.localhost` +- `http://docs..freenary.localhost` + +The slug comes from your git branch, so two worktrees run side by side. A shared Traefik container on port 80 routes these hostnames. Use Chrome or Firefox, which resolve `*.localhost` on their own. + +`bun run dev:logs` follows the logs, `bun run dev:down` stops everything, and `bun run dev:reset` deletes the database and starts over. + +There's no seed data. Create an account on the sign-in screen: with no email provider, sign-up signs you in at once. -**Without Docker for the apps.** PostgreSQL still runs in a container: +To work on emailed codes, start with `bun run dev:mail` instead. It adds a Mailpit inbox at `http://mail..freenary.localhost`, and the server sends every email there. + +## Run it without Docker for the apps + +Only PostgreSQL runs in Docker: ```bash cp .env.example .env @@ -39,50 +52,47 @@ docker compose up -d --wait postgres bun run dev ``` -`.env.example` holds development values for `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`, which Compose needs to read `docker-compose.yml`. The Prisma commands read `DATABASE_URL` from `apps/server/.env`. The API server runs on port `3000`, the web app on `3001`, and the docs on `4000`. +The server listens on port 3000, the web app on 3001 and the documentation site on 4000. -There's no seed data: create an account on the sign-in screen. Without an email provider, sign-up signs you in straight away. +## Checks -## Check your change - -Run what CI runs before you push: +CI runs `bun install --frozen-lockfile`, then these steps in order. Run them before you push: ```bash -bun install --frozen-lockfile -bun run check -bun run check-types -bun run check:fsd -bun run docs:check -bun run env:check -bun run build +bun run check # Lint & format +bun run check-types # Type check +bun run check:fsd # Architecture +bun run docs:check # Documentation +bun run env:check # Environment +bun run build # Build ``` -`bun run fix` fixes most lint and format issues. CI runs no tests, so run the test files your change touches with `bun test `. If you change a Compose file, reproduce CI's `compose` job as [`docs/engineering/local-stack.md`](https://github.com/suiramdev/freenary/blob/main/docs/engineering/local-stack.md) shows. Check interface changes in the browser, in light and dark mode. +`bun run fix` repairs most lint and format errors. A second CI job parses every Compose file. + +On each commit, lefthook runs three jobs on the staged files: -## Open a pull request +- `oxlint --fix`, then stages the fixes. +- `oxfmt --write`, then stages the fixes. +- `bun run env:sync`, when the commit touches `packages/env/src/schema*.ts`, a `docker-compose*.yml` file or `scripts/env-sync.ts`. -1. Branch with a descriptive name, such as `fix/login-redirect-loop`. -2. Sign off each commit with `git commit -s`, which certifies the [Developer Certificate of Origin](https://developercertificate.org). -3. Open the pull request against **`dev`**, not `main`, and fill in the template briefly. -4. If you changed behaviour that's documented, such as a screen, an API route, an environment variable or a script, update `apps/fumadocs/content/docs/next` in the same pull request. +## Tests -Maintainers handle tags, version bumps and releases. +CI runs no tests, and there's no root `test` script. Run the files your change touches: + +```bash +bun test packages/api/src/categorisation/dictionary.test.ts +``` -## Where the rules live +## Pull requests -- [`.github/CONTRIBUTING.md`](https://github.com/suiramdev/freenary/blob/main/.github/CONTRIBUTING.md): the full contribution rules, licensing and dependency licences. -- [`CONTEXT.md`](https://github.com/suiramdev/freenary/blob/main/CONTEXT.md): the product vision and domain terms. -- [`AGENTS.md`](https://github.com/suiramdev/freenary/blob/main/AGENTS.md): code standards, and the `AGENTS.md` next to each workspace. +Open your pull request against `dev`. `main` holds released code. [`CONTRIBUTING.md`](https://github.com/suiramdev/freenary/blob/dev/.github/CONTRIBUTING.md) has the rules: branch names, the pull request template, signed-off commits, documentation in the same change. -Engineering notes in [`docs/engineering/`](https://github.com/suiramdev/freenary/tree/main/docs/engineering): +Maintainers cut releases from `main` with the `Release` workflow. Don't tag, and don't bump a version. -- [Local stack](https://github.com/suiramdev/freenary/blob/main/docs/engineering/local-stack.md): both ways to run, hostnames, database commands, checks and common failures. -- [Platform](https://github.com/suiramdev/freenary/blob/main/docs/engineering/platform.md): workspaces, the path of a request, the build graph, auth and the instance configuration. -- [Web app](https://github.com/suiramdev/freenary/blob/main/docs/engineering/web-app.md) and [web UI](https://github.com/suiramdev/freenary/blob/main/docs/engineering/web-ui.md): Feature-Sliced Design layers, routing, components. -- [API](https://github.com/suiramdev/freenary/blob/main/docs/engineering/api.md): routers, budget aggregation, the taxonomy. -- [Data model](https://github.com/suiramdev/freenary/blob/main/docs/engineering/data-model.md): every Prisma model, enum and migration. -- [Categorisation](https://github.com/suiramdev/freenary/blob/main/docs/engineering/categorisation.md): how transactions get categories. -- [Data pipeline](https://github.com/suiramdev/freenary/blob/main/docs/engineering/data-pipeline.md): how the merchant dictionary is built. -- [Bank provider](https://github.com/suiramdev/freenary/blob/main/docs/engineering/bank-provider.md): the adapter interface for adding a provider. -- [Writing docs](https://github.com/suiramdev/freenary/blob/main/docs/engineering/writing-docs.md): how to write and check these pages. -- [Dependencies](https://github.com/suiramdev/freenary/blob/main/docs/engineering/dependencies.md) and [releasing](https://github.com/suiramdev/freenary/blob/main/docs/engineering/releasing.md): Renovate, and how a version ships. + + + diff --git a/apps/fumadocs/content/docs/next/get-started/before-you-install.mdx b/apps/fumadocs/content/docs/next/get-started/before-you-install.mdx index 7f4dc60..3ca2ab2 100644 --- a/apps/fumadocs/content/docs/next/get-started/before-you-install.mdx +++ b/apps/fumadocs/content/docs/next/get-started/before-you-install.mdx @@ -1,33 +1,36 @@ --- title: Before you install -description: What you need before you install Freenary - a server with Docker, a domain, and HTTPS. +description: What you need before you install Freenary - a server, a domain, and HTTPS. icon: Compass --- -Freenary runs on a server you control and is reached through your own domain over HTTPS. Check this list first, so the install takes one pass. +Freenary runs on a server you control, and you reach it through your own domain over HTTPS. Check this list first, so the install takes one pass. ## What you need -- **A server with Docker.** A VPS or a home server with Docker Engine and Docker Compose v2. A hosting panel such as [Dokploy](https://dokploy.com) or [Coolify](https://coolify.io) works too, and does most of the work for you. +- **A server.** A VPS or a home server with Docker Engine and Docker Compose v2, or a hosting panel such as [Dokploy](https://dokploy.com) or [Coolify](https://coolify.io) that runs Docker for you. - **A domain that points at the server.** One hostname is enough, for example `freenary.example.com`. Freenary serves the whole instance from it. -- **HTTPS from your platform.** Freenary doesn't ship its own proxy or certificates. Dokploy and Coolify handle HTTPS for you. On a plain Docker host, your own reverse proxy does. -- **About 15 minutes.** +- **HTTPS.** Freenary ships no proxy and no certificates. Dokploy and Coolify provide HTTPS. On a plain Docker host, your own reverse proxy does, or a tunnel. -On the last page, [Connect a bank](/docs/get-started/connect-a-bank), you'll also create a free Powens sandbox account, so you can connect a test bank. You don't need it to install. +You need HTTPS to link a bank: the bank provider only sends you back to an `https://` address, see [The redirect URI must be HTTPS](/docs/reference/bank-providers#the-redirect-uri-must-be-https). - + -Pick whichever fits your setup: - -- **Buy one.** Any registrar sells a domain for around 10 EUR a year. Add a DNS record that points a hostname, such as `freenary.example.com`, at your server's public IP address. -- **Free dynamic DNS, for a home server.** A service such as [DuckDNS](https://www.duckdns.org) gives you a free hostname that follows your home IP address. Your router must forward ports 80 and 443 to the server. -- **A tunnel, when you can't open ports.** [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/get-started/) publishes a hostname on a domain you've added to Cloudflare. [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) publishes a `*.ts.net` hostname over HTTPS. Both reach your server from the outside without any open port. +- **Buy a domain.** Add a DNS record that points a hostname, such as `freenary.example.com`, at your server's public IP address. +- **Free dynamic DNS, for a home server.** A service such as [DuckDNS](https://www.duckdns.org) gives you a hostname that follows your home IP address. Your router must forward ports 80 and 443 to the server. +- **A tunnel, when you can't open ports.** [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/get-started/) and [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) publish a hostname over HTTPS with no open port. -## Trying it on your own computer +## Try it on your own computer + +You can run Freenary on `localhost` to look around. Follow the **Docker Compose** tab of [Install Freenary](/docs/get-started/install), with these changes to `.env`: + +- Leave out the `BETTER_AUTH_URL` and `CORS_ORIGIN` lines. Their defaults, `http://localhost:3000` and `http://localhost:3001`, match the default ports. +- Keep `SERVER_PORT=127.0.0.1:3000` and `WEB_PORT=127.0.0.1:3001`, so only your computer reaches the instance. +- If another program holds one of those ports, or PostgreSQL's `5432`, pick free ones. For the web or API port, also set `BETTER_AUTH_URL` and `CORS_ORIGIN` to `http://localhost:`. For PostgreSQL, set `POSTGRES_PORT`. -You can run Freenary on `localhost` to look around: follow the **Docker Compose** tab of the install page, keep only the `FREENARY_VERSION`, `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET` lines in `.env`, and open `http://localhost:3001`. You can't connect a bank there, though: Powens only accepts an HTTPS address to send you back to after the bank sign-in. Use a server and a domain for a real instance. +Then open `http://localhost:3001`. You can't link a bank there, because the address isn't HTTPS. @@ -14,34 +12,36 @@ Powens is the bank provider that links Freenary to banks. You need to be the ope ### Create a Powens sandbox -Everything here happens in the [Powens console](https://console.powens.com). The sandbox is free and needs no contract. +Everything here happens in the [Powens console](https://console.powens.com). A sandbox domain is free and needs no contract. 1. [Sign up](https://console.powens.com/auth/register), confirm your email, and fill in the organization details. -2. Create a domain. Powens adds `-sandbox` to its name, so `acme` becomes `acme-sandbox`. Note this full name. -3. Open the domain's **Client Applications** and add a client. Register exactly one redirect URI, with your own hostname: +2. Create a domain. Powens adds `-sandbox` to the name, so `acme` becomes `acme-sandbox`. Note the full name. +3. Open the domain's **Client Applications**, press **Add a client**, and register this redirect URL, with your own hostname: ```text https://freenary.example.com/callback/powens ``` - Powens sends you back to this address after the bank sign-in. It must match your Freenary address character for character. + Powens sends you back to this address after the bank sign-in. It's your instance's address followed by `/callback/powens`, character for character. 4. Note the client ID and the client secret. -5. Activate the connectors (banks) you want to offer, or all of them. A domain with no active connector shows an empty bank list. +5. Activate the connectors (banks) you want to offer, or all of them. With no active connector, the bank list in Freenary stays empty even when the credentials are right. + +Powens' console changes over time, so a label may differ from this list. Its [quick start](https://docs.powens.com/documentation/integration-guides/quick-start) has the current steps. -### Enter it in the setup screen +### Enter it on the setup screen Go back to the setup screen, or open `https://freenary.example.com/setup`. -1. On **Bank provider**, pick **Powens** and press **Continue**. +1. On the **Bank** step, pick **Powens** and press **Continue**. 2. Fill in **Powens domain** (for example `acme-sandbox`), **Client ID** and **Client secret**. -3. Press **Save and continue**. Freenary checks the credentials with Powens before it saves them. -4. On **Email delivery**, choose **Skip for now** and press **Continue**. You can add email later, see [Email](/docs/run/email). -5. Press **Finish setup**. The server restarts to apply the credentials, and the page reloads by itself when it's back. +3. Press **Save and continue**. Freenary tests the credentials with Powens first. If Powens refuses them, the step says so and you can correct them. +4. On the **Email** step, choose **Skip for now** and press **Continue**. You can add email later, see [Email](/docs/run/email). +5. Press **Finish setup**. The server restarts to apply the credentials: the page shows **Applying your settings…** and reconnects on its own. If it shows **The server did not restart** instead, see [The server does not restart after setup](/docs/run/troubleshooting#the-server-does-not-restart-after-setup). @@ -49,12 +49,12 @@ Go back to the setup screen, or open `https://freenary.example.com/setup`. ### Link the test bank -Freenary now asks two quick questions. +Freenary now asks two questions. -1. **Tax residency.** Pick the countries you pay tax in, then press **Continue**. France has the fullest support today. -2. **Connect your bank.** Search for `Connecteur de test`, the Powens test bank, and press **Connect**. +1. **Tax residency.** Pick every country you pay tax in, then press **Continue**. These countries decide which banks the list shows, and you can't [change them later](/docs/use/account#first-steps). Countries other than France show **Limited support**. +2. **Connect your bank.** Search for `Connecteur de test`, the test bank Powens offers in every domain, and press **Connect**. 3. On the Powens page, sign in with any user name and the password `1234`. -4. Freenary brings you back and lists the new connection. Press **Finish** to open Freenary. +4. Freenary brings you back to the same step with a message such as **Connected 2 accounts**. Press **Finish (1)** to open Freenary. @@ -62,7 +62,7 @@ Freenary now asks two quick questions. ### See your budget fill -Open **Budget**. Freenary syncs the new connection and the test transactions appear, each one sorted into a category. +Open **Budget**. Freenary syncs the new connection when the page opens, and the test transactions appear, each one sorted into a category. @@ -70,17 +70,17 @@ Open **Budget**. Freenary syncs the new connection and the test transactions app -**The test bank isn't in the list?** It only shows up if your Powens domain leaves it active and visible. Check the connectors in the Powens console. If the list is empty, check that your tax residency includes a country your activated banks cover. +**The test bank isn't in the list?** Check that it's active in your Powens domain. For an empty list, see [The bank list is empty](/docs/run/troubleshooting#the-bank-list-is-empty). -When you're ready for your real banks, connect them the same way from **Settings**. A sandbox domain holds a limited number of connections. For a production domain or the other provider, Enable Banking, see [Bank providers](/docs/reference/bank-providers). +When you're ready for your real banks, connect them from **Settings**, see [Bank connections](/docs/use/bank-connections). A sandbox domain holds a limited number of connections, and a production domain needs a contract with Powens. For Enable Banking, the other bank provider, see [Bank providers](/docs/reference/bank-providers). -1. In a Dokploy project, create a **Compose** service and keep the type **Docker Compose**. +1. In a Dokploy project, create a **Compose** service of type **Docker Compose**. 2. In **General**, set the provider to **Git**: - Repository URL: `https://github.com/suiramdev/freenary.git` - - Branch: `main` + - Branch: `dev` - Compose Path: `./deploy/docker-compose.yml` 3. Generate two secrets on any machine with OpenSSL: @@ -22,7 +22,7 @@ Pick the tab that matches your server. Each one ends with Freenary running at `h echo "BETTER_AUTH_SECRET=$(openssl rand -hex 32)" ``` -4. Open **Environment** and add these four lines, with your hostname and the two lines the commands printed: +4. In **Environment**, add these lines, with your hostname and the two printed lines: ```dotenv FREENARY_URL=https://freenary.example.com @@ -31,24 +31,30 @@ Pick the tab that matches your server. Each one ends with Freenary running at `h BETTER_AUTH_SECRET= ``` -5. Open **Domains** and add one domain: service `web`, host `freenary.example.com`, container port `3001`, HTTPS on. +5. In **Domains**, add one domain: service `web`, host `freenary.example.com`, container port `3001`, HTTPS on. 6. Press **Deploy**. -Prefer one paste? Run `bun run deploy:dokploy` in a clone of the repository and import the string under **Advanced** → **Import**. The template generates the secrets and a free `http://` test hostname. To use your own domain, change the host under **Domains** (HTTPS on), set `FREENARY_URL` to the same `https://` address, and deploy again. +**Prefer one paste?** In a clone of the repository, run: + +```bash +bun run deploy:dokploy +``` + +It prints a template. Create a Compose service, open **Advanced** → **Base64 import**, and paste it. The template generates both secrets and a test hostname on `http://`. That hostname can't link a bank, so change the host under **Domains** to yours with HTTPS on, set `FREENARY_URL` to the same `https://` address, and deploy again. 1. In a Coolify project, select **New** → **Docker Compose Empty**. -2. Paste the contents of [`deploy/coolify.yml`](https://raw.githubusercontent.com/suiramdev/freenary/main/deploy/coolify.yml). Coolify generates the database password and the auth secret for you. -3. Open the `web` service and set its domain to your hostname followed by the container port: +2. Paste the contents of [`deploy/coolify.yml`](https://raw.githubusercontent.com/suiramdev/freenary/dev/deploy/coolify.yml). Coolify generates the database password and the auth secret. +3. Set the domain of the `web` service to your hostname followed by the container port: ```text https://freenary.example.com:3001 ``` - The `:3001` only tells Coolify which container port to use. Visitors still reach the normal HTTPS address. + The `:3001` tells Coolify which container port to route to. Visitors still use the plain HTTPS address. 4. Press **Deploy**. @@ -56,13 +62,13 @@ Prefer one paste? Run `bun run deploy:dokploy` in a clone of the repository and -Use this tab if you run your own reverse proxy on the server, such as Caddy or nginx. Freenary listens on the loopback interface only, and your proxy sends `https://freenary.example.com` to `127.0.0.1:3001`. See [Reverse proxy](/docs/reference/reverse-proxy) for proxy examples, and for proxies that run in Docker. +Use this tab when you run your own reverse proxy on the server, such as Caddy or nginx. Freenary listens on the loopback interface only, and your proxy sends `https://freenary.example.com` to `127.0.0.1:3001`. [Reverse proxy](/docs/reference/reverse-proxy) has proxy examples. 1. Download the Compose file: ```bash mkdir freenary && cd freenary - curl -O https://raw.githubusercontent.com/suiramdev/freenary/main/docker-compose.yml + curl -O https://raw.githubusercontent.com/suiramdev/freenary/dev/docker-compose.yml ``` 2. Create the `.env` file. Replace the hostname on both URL lines first: @@ -79,7 +85,7 @@ Use this tab if you run your own reverse proxy on the server, such as Caddy or n EOF ``` - Both URLs are the same on purpose: the web app forwards API calls to the server, so one hostname serves everything. + Both URLs are the same address: the web app forwards API calls to the server, so one hostname serves everything. 3. Start Freenary: @@ -95,31 +101,44 @@ Use this tab if you run your own reverse proxy on the server, such as Caddy or n `FREENARY_VERSION=latest` follows each new release. To stay on one version, see [Updates and backups](/docs/run/updates-and-backups). - +## Check that it works -Keep `BETTER_AUTH_SECRET` once the instance is running. Freenary uses it to encrypt the bank and email credentials you enter later. A new secret makes them unreadable. +Freenary starts in order: - +1. The database starts and reports healthy. +2. `migrate` prepares the database, then exits. It runs again on every start. +3. The server starts once the migrations succeed. +4. The web app starts once the server reports healthy. -## Check that it works +Then open `https://freenary.example.com`. You should see the **Welcome to Freenary** sign-in screen. Something else, or a stopped container? See [Freenary won't start](/docs/run/troubleshooting#freenary-wont-start). -Open `https://freenary.example.com` in a browser. The first start takes a minute while the database is prepared. You should see the **Welcome to Freenary** sign-in screen. +## Create your account now -Something else? See [Troubleshooting](/docs/run/troubleshooting). + -## Create your account now +The operator is the only account that can change the instance's settings. Until someone claims it, anyone who reaches your address can, so create your account right away. When email is configured, the role goes to the first account that enters its emailed code. -**The first account to sign in becomes the operator.** It's the only account that can change the instance's settings. Until you create it, anyone who finds your address can claim that role, so do it right away. + 1. Type your email address. Freenary shows a **Name** and a **Password** field. 2. Fill them in and press **Create account**. +If someone else got there first, see [The wrong account became the operator](/docs/run/troubleshooting#the-wrong-account-became-the-operator). + ## The setup screen -You land on the setup screen, where the operator enters the bank and email credentials. You have two options: +You land on the setup screen, where the operator enters the bank and email credentials. Until you finish it, every account that signs in is sent there. It has three steps: **Bank**, **Email** and **Done**. + +- **Connect a bank now.** Keep this tab open and continue to [Connect a bank](/docs/get-started/connect-a-bank). +- **Skip it for now.** On **Bank** and **Email**, choose **Skip for now** and press **Continue**. On **Done**, press **Finish setup**. + +You can come back later at `https://freenary.example.com/setup`. -- **Connect a bank now.** Keep this tab open and continue to the next page. -- **Skip it for now.** Choose **Skip for now** and press **Continue** on each step, then press **Finish setup**. You can come back any time at `https://freenary.example.com/setup`. + + +Keep `BETTER_AUTH_SECRET` once the instance runs. Freenary encrypts the bank and email credentials you enter on the setup screen with it, so a new secret makes them unreadable. See [What a rotation costs](/docs/reference/security#what-a-rotation-costs). + + /` | Every procedure, inside the oRPC envelope. The web app uses this | +| `GET` or `POST /api/auth/*` | Better Auth: sign-up, sign-in, sessions, one-time codes, two-factor, passkeys | +| `POST /rpc//` | Every procedure, in the oRPC envelope | | `POST /api-reference//` | The same procedures, plain JSON in and out | -| `GET /api-reference` | The reference UI (Scalar, loaded from a CDN) | +| `GET /api-reference` | The reference page (Scalar, loaded from a CDN) | | `GET /api-reference/spec.json` | The OpenAPI document | -| `POST /ai/chat` | The assistant stream. Not a procedure | +| `POST /ai/chat` | The assistant stream | +| `GET /` | `OK` on the API server. With one hostname, `/` is the web app | -There's no version prefix. An unknown procedure under `/rpc` or `/api-reference` gets `404` with the text `Not Found`; any other unknown path gets `404` with `NOT_FOUND`. `OPTIONS` on any path gets the CORS preflight answer, `204`. +There's no version prefix. `budget.getTransactions` is `/api-reference/budget/getTransactions`. -`/rpc` accepts any method except `GET`, which gets `405` with the code `METHOD_NOT_SUPPORTED`. `/api-reference` procedures accept `POST` only. +`/api-reference` is a live API, not only documentation: see [Security](/docs/reference/security#data). - +## Methods -`/api-reference` is a live API, not just documentation. Anyone with a session can call every procedure there, and the reference UI has a send button. Block the prefix at your proxy if you don't want it public. With one hostname, block it on that hostname. +- `/api/auth/*` takes `GET` and `POST`; other methods get `405 Method Not Allowed`. +- `/rpc` refuses `GET` with `405` and the code `METHOD_NOT_SUPPORTED`. +- `/api-reference/` takes `POST` only; other methods get `404 Not Found`. +- `OPTIONS` gets `204` on any path. An unknown path gets `404` with the text `NOT_FOUND`. An unknown procedure under `/rpc` or `/api-reference` gets `404` with the text `Not Found`, not the JSON error body. - +## CORS -### The OpenAPI document +The API server allows one origin, `CORS_ORIGIN`, with credentials, the methods `GET`, `POST` and `OPTIONS`, and the headers `Content-Type` and `Authorization`. A browser client must run on that origin; server-to-server code can ignore CORS. -`openapi` is `3.1.1`, `info.title` is `API Reference`, `info.version` is `0.0.0`, and `servers[0].url` is the origin of `BETTER_AUTH_URL` plus `/api-reference` (any path in `BETTER_AUTH_URL` is dropped). For a generated client, note that: +## Session cookie -- It declares no security scheme and no tags. [Procedures](/docs/reference/procedures) says which ones need a session. -- No procedure declares an output schema, so every response schema accepts any value. Use the output tables in [Procedures](/docs/reference/procedures). -- It omits `POST /ai/chat`. - -## Authentication - -The session cookie is the only credential. There's no API key and no bearer token: the instance registers neither plugin, and an `Authorization` header alone gets `401`. +The session cookie is the only credential. There's no API key, and an `Authorization: Bearer` header gets `401`. | Property | Value | | --- | --- | -| Name | `__Secure-better-auth.session_token` when `BETTER_AUTH_URL` is `https://`, otherwise `better-auth.session_token` | -| Attributes | `HttpOnly`, `Path=/`, `SameSite=Lax` (or `None` on two hostnames without `AUTH_COOKIE_DOMAIN`), `Secure` over HTTPS, `Domain` when `AUTH_COOKIE_DOMAIN` is set | -| Lifetime | 7 days. A sign-in with `rememberMe: false` gets a browser-session cookie instead | +| Name | `__Secure-better-auth.session_token` when `BETTER_AUTH_URL` starts with `https://`, otherwise `better-auth.session_token` | +| Attributes | `HttpOnly`, `Path=/`, `SameSite=Lax`, `Secure` over HTTPS. `Domain` when `AUTH_COOKIE_DOMAIN` is set. `SameSite=None` on two hostnames without it | +| Lifetime | `Max-Age=604800`, 7 days. [Sign-in](/docs/run/sign-in) explains renewal | -Five procedures answer without a session: `healthCheck`, `auth.capabilities`, `auth.accountExists`, `auth.viewer` and `instance.status`. The rest return: +A sign-in with `rememberMe: false` drops `Max-Age` and adds a `better-auth.dont_remember` cookie. -```json -{ - "defined": false, - "code": "UNAUTHORIZED", - "status": 401, - "message": "Unauthorized" -} -``` - -Operator procedures (`instance.describe`, `check`, `save`, `complete`) also return `FORBIDDEN` (403) to any account that isn't the operator. - -### Sign in and call a procedure +To create an account without a browser, send `email`, `password` and `name` to `/api/auth/sign-up/email`. Without an email provider, the answer carries a session cookie at once. ```bash curl -c jar.txt -X POST https://freenary.example.com/api/auth/sign-in/email \ @@ -74,142 +61,106 @@ curl -c jar.txt -X POST https://freenary.example.com/api/auth/sign-in/email \ curl -b jar.txt -X POST https://freenary.example.com/api-reference/onboarding/getStatus \ -H 'Content-Type: application/json' -d '{}' -# {"completed":false,"taxCountries":[]} ``` -The first call writes the session cookie to `jar.txt`; the second sends it back. An account with two-factor gets no cookie from the first call: the answer asks for the second factor. - -`POST /api/auth/sign-up/email` with `name`, `email` and `password` creates an account. With no email provider, it signs you in at once. With one, it answers `token: null`, emails a one-time code, and you sign in after confirming. - -### CORS - -The API server allows one origin, `CORS_ORIGIN`, with credentials, methods `GET`, `POST` and `OPTIONS`, and headers `Content-Type` and `Authorization`. A browser client must run on that origin and send credentials. Server-to-server callers can ignore CORS. +The first call stores the cookie in `jar.txt`; the second sends it. With two-factor on, the first call returns no session cookie and asks for the second factor. ## Request shape -A procedure's dot path becomes its URL: `budget.getTransactions` is `/api-reference/budget/getTransactions`, and `healthCheck` is `/api-reference/healthCheck`. - | | `/rpc` | `/api-reference` | | ------------ | -------------------- | ---------------- | | Request body | `{"json": }` | `` | | No input | `{}` | `{}` | | Success body | `{"json": }` | `` | -Use `/api-reference` for hand-written calls and `/rpc` with the oRPC client. There's no published SDK: the `AppRouter` type lives inside the repository. - - - -Send `{}` for a procedure with no input, never `{"json":null}`. `null` fails on `budget.syncAccounts`, whose optional input accepts an absent value but not `null`. - - +For a procedure with no input, send `{}`, never `null`: `budget.syncAccounts` refuses `null`. ## Errors -| Field | Meaning | -| --------- | -------------------------------------------------------------- | -| `defined` | Always `false` in Freenary | -| `code` | One of the codes below | -| `status` | The HTTP status, repeated | -| `message` | An English message. Not translated | -| `data` | On a validation failure, `data.issues` holds the schema issues | +A protected procedure called with no session answers `401`: -On `/api-reference` that object is the body; on `/rpc` it's inside `json`. +```json +{ + "json": { + "defined": false, + "code": "UNAUTHORIZED", + "status": 401, + "message": "Unauthorized" + } +} +``` + +That is the `/rpc` body; on `/api-reference`, the object inside `json` is the whole body. `defined` is always `false`, and `message` is English. On a schema failure, `data.issues` lists each issue's `path` and `message`. | Code | HTTP | Cause | | --- | --- | --- | -| `BAD_REQUEST` | 400 | Input failed the schema (`Input validation failed`), or a procedure refused it with its own message | +| `BAD_REQUEST` | 400 | The input failed the schema (`Input validation failed`), or the procedure refused it with its own message | | `UNAUTHORIZED` | 401 | No session on a protected procedure | | `FORBIDDEN` | 403 | An operator procedure called by another account | -| `NOT_FOUND` | 404 | A bank connection, transaction, category, bank provider, active conversation, integration or variant doesn't match | +| `NOT_FOUND` | 404 | A bank connection, transaction, category, bank provider, conversation or integration doesn't match | | `METHOD_NOT_SUPPORTED` | 405 | `GET` on `/rpc` | | `CONFLICT` | 409 | A custom category name already exists | -| `TOO_MANY_REQUESTS` | 429 | `auth.accountExists` or `assistant.saveTurn` over its limit | -| `INTERNAL_SERVER_ERROR` | 500 | An unhandled failure, such as a provider network error | -| `NOT_IMPLEMENTED` | 501 | `instance.check` with no check for that variant | +| `TOO_MANY_REQUESTS` | 429 | Over a [rate limit](#rate-limits) | +| `INTERNAL_SERVER_ERROR` | 500 | An unhandled failure, such as a bank provider network error | | `SERVICE_UNAVAILABLE` | 503 | The bank provider couldn't list banks for a country | -## The assistant stream +## Rate limits -```http -POST /ai/chat -``` +Counters live in the database. Better Auth limits `/api/auth/*` per caller address and path: -It uses the same session cookie. The body is the AI SDK's UI message request plus `locale`: +| Path | Limit | +| --- | --- | +| `/sign-in/email`, `/sign-in/social` | 10 per 60 s | +| `/sign-up/email` | 5 per 300 s | +| `/two-factor/verify-totp` | 10 per 300 s | +| `/two-factor/verify-backup-code` | 5 per 300 s | +| Other `/two-factor/*` paths | 3 per 10 s | +| `/passkey/generate-authenticate-options`, `/passkey/verify-authentication` | 20 per 60 s | +| Other `/sign-in*` and `/sign-up*` paths, `/change-password`, `/change-email` | 3 per 10 s | +| Password reset and one-time-code paths | 3 per 60 s | +| Any other path | 100 per 60 s | + +Two procedures have their own limit: -| Field | Required | Notes | +| Surface | Limit | Counted per | | --- | --- | --- | -| `messages` | No, default `[]` | Only the last entry is read. It must be a user message whose `parts` are text parts: `{ "type": "text", "text": "" }` | -| `trigger` | No | `regenerate-message` redoes the last stored answer. Anything else asks a new question | -| `messageId` | No | With `regenerate-message`, the id of the last stored assistant message. Any other id becomes a new question | -| `locale` | No | `en` or `fr-FR` style | +| `auth.accountExists` | 20 per 60 s | Caller address | +| `assistant.saveTurn` and `POST /ai/chat`, together | 30 per 300 s | Account | -Earlier turns are rebuilt from the stored conversation, so one user message is a complete request, and a caller can't inject context. +The other 40 procedures have no limit. Pace `budget.syncAccounts`, `budget.recategorise` and `budget.getSankeyData` yourself: each does heavy work. -Success is `200` with an AI SDK UI message stream (`content-type: text/event-stream`, `x-vercel-ai-ui-message-stream: v1`, `x-accel-buffering: no`). Chunks include `start`, `text-delta`, `reasoning-delta`, `tool-input-available`, `tool-output-available`, `finish` and `error`. The turn is stored when the answer finishes; an aborted, failed or empty stream stores nothing. +The caller address comes from `X-Forwarded-For`. Callers Freenary can't place share one bucket: see [Trusted proxies](/docs/reference/reverse-proxy#trusted-proxies). -Refusals are plain-text bodies, checked in this order: +## OpenAPI document -| Status | Body | Cause | -| --- | --- | --- | -| 401 | `unauthorized` | No session | -| 503 | `unconfigured` | No model configured | -| 429 | `rate_limited` | Over the question limit, or the limiter's database failed | -| 400 | `bad_request` | Not JSON, fails the schema, last message isn't a text-only user message, or the question exceeds 8000 characters | - -The rate limit is charged before the body is checked, so a malformed request still counts. +`GET /api-reference/spec.json` returns OpenAPI `3.1.1`, with `info.title` `API Reference` and `info.version` `0.0.0`. For a generated client: -## Limits +- `servers[0].url` is the origin of `BETTER_AUTH_URL` plus `/api-reference`; the request's `Host` doesn't change it. +- It declares no security scheme. [Procedures](/docs/reference/procedures#all-procedures) says which ones need a session. +- No procedure declares an output schema, so every response schema accepts any value. +- It omits `POST /ai/chat`. -### Rate limits +## Assistant stream -Better Auth limits `/api/auth/*` per caller address and path. Counters live in the database. +`POST /ai/chat` takes the session cookie and the AI SDK's UI message request, plus an optional `locale` such as `fr-FR`. It reads only the last message, which must be a user message with text `parts`; earlier turns come from the stored conversation. Success is `200` with an AI SDK UI message stream. -| Path | Limit | -| --- | --- | -| `/api/auth/sign-in/email`, `/api/auth/sign-in/social` | 10 per 60 s | -| `/api/auth/sign-up/email` | 5 per 300 s | -| `/api/auth/two-factor/verify-totp` | 10 per 300 s | -| `/api/auth/two-factor/verify-backup-code` | 5 per 300 s | -| Other `/api/auth/two-factor/*` | 3 per 10 s | -| `/api/auth/passkey/generate-authenticate-options`, `/api/auth/passkey/verify-authentication` | 20 per 60 s | -| Other `/api/auth/sign-in*` and `/api/auth/sign-up*`, `/api/auth/change-password`, `/api/auth/change-email` | 3 per 10 s | -| Password reset and one-time-code paths (with an email provider) | 3 per 60 s | -| Every other `/api/auth/*` path | 100 per 60 s | - -The caller address comes from `X-Forwarded-For`. See [Trusted proxies](/docs/reference/reverse-proxy#trusted-proxies). +Refusals are plain text, checked in this order: -| Surface | Limit | Counted per | +| Status | Body | Cause | | --- | --- | --- | -| `auth.accountExists` | 20 per 60 s | Caller address | -| `assistant.saveTurn` and `POST /ai/chat`, shared | 30 per 300 s | Account | - -The other 40 procedures have no limit. `budget.syncAccounts` calls the bank provider, `budget.recategorise` reprocesses every transaction, and `budget.getSankeyData` reads the whole date range, so pace those yourself. - -### Sizes +| 401 | `unauthorized` | No session | +| 503 | `unconfigured` | The instance has no model. See [Configuration](/docs/reference/configuration#assistant) | +| 429 | `rate_limited` | Over the limit | +| 400 | `bad_request` | Not JSON, not a text-only user message, or a question over 8000 characters | -| Limit | Value | -| --- | --- | -| Request body | No application limit, only Bun's default | -| `budget.getTransactions` page | `limit` 1 to 100, default 50. The only paged procedure: pass `nextCursor` as `cursor` | -| `budget.getMerchants` | `limit` 1 to 100, default 30 | -| Budget lines | 200 per account, label 60 characters, amount 0 to 2147483647 minor units | -| Custom category name | 1 to 40 characters | -| Assistant question | 8000 characters | -| `assistant.saveTurn` answer | 200000 characters as JSON | -| `from` / `to` date range | No maximum, and no check that `from` is before `to` | +The limit is charged before the body is read, so a malformed request still counts. - - diff --git a/apps/fumadocs/content/docs/next/reference/bank-providers.mdx b/apps/fumadocs/content/docs/next/reference/bank-providers.mdx index 06ff62a..73100a8 100644 --- a/apps/fumadocs/content/docs/next/reference/bank-providers.mdx +++ b/apps/fumadocs/content/docs/next/reference/bank-providers.mdx @@ -1,12 +1,12 @@ --- title: Bank providers -description: Powens and Enable Banking compared, how to set each one up, what happens to a bank connection over time, and the merchant dictionary. +description: Powens and Enable Banking compared, the credentials and callback each one needs, what a sync reads, and what switching costs. icon: Landmark --- -Freenary never talks to a bank directly. A bank provider holds that link, and this page covers the two Freenary supports. For your first connection, follow [Connect a bank](/docs/get-started/connect-a-bank). +Freenary never talks to a bank directly. A bank provider holds that link, and Freenary supports two: Powens and Enable Banking. For your first connection, follow [Connect a bank](/docs/get-started/connect-a-bank). -**Use Powens unless you have a reason not to.** It's the default, its sandbox is free, and it's the only provider that imports accounts and balances as well as transactions. +**Use Powens unless you have a reason not to.** It's the default, and it's the only provider that imports account types, balances and accounts opened after linking. ## Compare the two @@ -14,38 +14,38 @@ Freenary never talks to a bank directly. A bank provider holds that link, and th | --- | --- | --- | | Provider id (`BANKING_PROVIDER`) | `powens` (default) | `enable-banking` | | Credentials | `POWENS_DOMAIN`, `POWENS_CLIENT_ID`, `POWENS_CLIENT_SECRET` | `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY` | -| Redirect URI to register | `https:///callback/powens` | `https:///callback/enable-banking` | -| Transactions | Yes | Yes | -| Accounts, account type and balances | Yes | No | -| Investment holdings | Yes | No | +| Redirect URI to register | `https:///callback/powens` | `https:///callback/enable-banking` | +| Accounts at linking | Yes | Yes | +| Accounts opened after linking | Yes, refreshed on every sync | No | +| Account type and balances | Yes | No: every account stays `UNKNOWN`, with no balance | +| Investment holdings | Yes, for investment accounts | No | | Bank logo and BIC in the bank list | No | Yes | -| Consent length Freenary asks for | Set by Powens and the bank | 90 days | +| Consent length Freenary asks for | None; Powens and the bank decide | 90 days | +| Sync window sent | Start date only | Start and end date | -On Enable Banking, every account keeps the type `UNKNOWN`, balances stay empty, no holdings are stored, and an account you open at the bank after linking never appears. No screen shows balances or holdings yet, so this changes what's stored rather than what you see today. +Both providers list banks per country. A country where the provider has no bank shows an empty list, so check the provider's bank catalogue before you choose. -Both providers list banks per country. A country with no bank at that provider shows an empty list, so check the provider's bank catalogue before you choose. +## The redirect URI must be HTTPS -The redirect URI uses `CORS_ORIGIN`, which is your public URL (`FREENARY_URL` on the hosting-panel files). It must be HTTPS: Powens refuses `http://localhost`, which is why a bank can't be linked from an instance on your own computer. +Freenary builds the redirect URI from `CORS_ORIGIN`, the public URL of the web app. `FREENARY_URL` fills it on `deploy/docker-compose.yml`, and Coolify fills it from the `web` domain. Register that URL at the provider character for character, with `https://`. An instance on `http://localhost` can't complete a bank link. ## Powens The console walkthrough (account, sandbox domain, client application, redirect URI, connectors) is in [Connect a bank](/docs/get-started/connect-a-bank). Enter the three values on the setup screen. Details that matter later: -- **Powens domain.** Use the full short name, such as `acme-sandbox`. Freenary also accepts `acme-sandbox.biapi.pro`, but not a value starting with `https://`. -- **Connectors.** A new domain has none active. A domain with no active connector returns an empty bank list, which looks like a configuration error even when the credentials are right. -- **Which banks appear.** Freenary hides connectors marked hidden or restricted, and keeps those that offer the Bank or Wealth products. Without Wealth, investment connectors drop out. -- **Test bank.** Every domain has `Connecteur de test`. Any user name works, and the password is `1234`. It appears only while your domain leaves it visible. -- **Sandbox limit.** A sandbox domain holds 50 connections. A production domain needs a contract with Powens. -- **Webhooks.** Not needed. Freenary has no webhook receiver, so skip that step in the console. +- **Powens domain.** Use the short name, such as `acme-sandbox`. `acme-sandbox.biapi.pro` also works. A value that starts with `https://` doesn't: Freenary adds the scheme itself, so every call fails. +- **Connectors.** A new domain has none active, and a domain with no active connector returns an empty bank list. That looks like a configuration error even when the credentials are right. +- **Which banks appear.** Freenary hides connectors marked hidden or restricted, and keeps those that offer the Bank or Wealth product. +- **Webhooks.** Not needed. Freenary has no webhook receiver. ## Enable Banking 1. In the [Enable Banking control panel](https://enablebanking.com/sign-in/), register an application and note its application ID. -2. Download the application's private key. Enable Banking gives it to you only once. -3. Register the redirect URL `https:///callback/enable-banking`, character for character. -4. On the setup screen, pick **Enable Banking** and fill in **Application ID** and **Private key (PEM)**. +2. Download the application's private key. +3. Register the redirect URI `https:///callback/enable-banking`. +4. On the setup screen, pick Enable Banking and fill in the application ID and the private key. -In an environment file, the key fits on one line with literal `\n` sequences, which Freenary turns back into newlines. A multi-line PEM also works. Passphrase-protected keys aren't supported. +In an environment file, the key fits on one line with literal `\n` sequences, which Freenary turns back into newlines. A multi-line PEM also works. A key protected by a passphrase doesn't. ```dotenv BANKING_PROVIDER=enable-banking @@ -55,47 +55,29 @@ ENABLE_BANKING_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n\n-----END PR Freenary asks for 90 days of consent. A bank can grant less, and Freenary doesn't track a shorter consent, so such a connection stops importing earlier. -## Switching providers - -`BANKING_PROVIDER` decides which provider starts the **next** bank connection. An existing connection remembers its provider and keeps using it for every sync and for disconnecting. - - - -Saving a provider on the setup screen erases the stored credentials of the other one, and connections made with it can then no longer sync or disconnect. To move to another provider and keep old connections working, set the old provider's credentials in the environment before you switch. Users then reconnect their banks when they're ready. - - +## What a sync reads -## With no provider - -Nothing fails at startup. The bank step disappears from the first-run flow, and Settings shows `Bank linking is unavailable` in place of the bank section. An incomplete set of credentials counts as no provider, without any message, so check that every field is filled. - -If a provider call fails, the bank list names each country it couldn't load and still shows the others. An empty list without that message means the provider has no bank there. - -## A bank connection over time +Powens receives only the start of the [sync window](/docs/use/bank-connections#sync) and returns everything since. Enable Banking receives both ends. -| Event | What happens | -| --- | --- | -| Link | Freenary stores the connection and one row per account the bank returned | -| First sync | Reads 90 days of history per account | -| Later sync | Resumes from the date of the last sync. A sync where some accounts failed still moves that date, so a forced sync is the way to fill a gap | -| Forced sync | Reads the 90-day window again | -| Disconnect | Asks the provider to revoke access, then deletes the connection, its accounts, transactions and holdings | +## Switching providers -Freenary has no scheduler and no webhook receiver: data only arrives when a user syncs. A failed sync doesn't change the connection's status, and there's no reconnect action. When consent ends, the user disconnects the bank and connects it again, which creates a new connection with a fresh 90-day import. See [Bank connections](/docs/use/bank-connections). +`BANKING_PROVIDER` decides which provider starts the **next** bank connection. An existing connection remembers its provider, and uses it for every sync and for disconnecting. -Provider tokens are stored in the database in plain text: the per-user Powens token and the session id of each connection. Anyone who can read the database can pull bank data through the provider. Protect the database and its dumps. +Saving a provider on the setup screen erases the stored credentials of the other one. Values set in the environment stay. Connections made with the erased provider can no longer sync. Disconnecting one removes it from Freenary without asking the provider to revoke access, and the user sees a warning to revoke it at their bank. -## The merchant dictionary +To switch and keep old connections working, set the old provider's credentials in the environment before you save the new one on the setup screen. Users then disconnect and reconnect each bank when they're ready. + +## With no provider -Freenary names merchants from a prebuilt dictionary before falling back to card codes and keyword rules. It's built into the server image, so no transaction leaves your instance for it. +Nothing fails at startup. Onboarding skips the connect step, and Settings shows `Bank linking is unavailable` in place of the bank section. An incomplete set of credentials counts as no provider; the setup screen marks it incomplete. -A GitHub workflow rebuilds it every Monday and publishes `merchant-data.tar.gz` on a release tagged `data-`. The server image downloads the newest one at build time. If the file is missing, Freenary logs `Dictionary file not found` and categorises without it. +If a provider call fails, the bank list names each country it couldn't load and still shows the others. -`DICTIONARY_PUBLIC_KEY` must stay unset with the published dictionary. See [Configuration](/docs/reference/configuration#categorisation). +The provider tokens live in the database. See [Data](/docs/reference/security#data) for what someone who can read it can do with them. @@ -107,7 +89,7 @@ A GitHub workflow rebuilds it every Monday and publishes `merchant-data.tar.gz` diff --git a/apps/fumadocs/content/docs/next/reference/configuration.mdx b/apps/fumadocs/content/docs/next/reference/configuration.mdx index 31d3521..09ea6d4 100644 --- a/apps/fumadocs/content/docs/next/reference/configuration.mdx +++ b/apps/fumadocs/content/docs/next/reference/configuration.mdx @@ -1,95 +1,45 @@ --- title: Configuration -description: Every environment variable Freenary reads, which values belong to the setup screen, where those values are stored, and what stops a start. +description: Every environment variable Freenary reads, where to set it, which values the setup screen owns, and what stops a start. icon: Cog --- -Look up any variable here: its default, what it does, and what happens when it's missing or wrong. Bank and email credentials normally go in the setup screen, not in a file. +Each variable, its default, what it does, and what happens when it's missing or wrong. ## Where a value goes | You run | Put variables in | Required | | --- | --- | --- | -| `deploy/docker-compose.yml` (Dokploy, Portainer, a proxy on a Docker network) | The panel's environment, or a `.env` next to the file | `FREENARY_URL`, `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET` | -| `deploy/coolify.yml` | The `server` service's `environment:` list, then the resource's **Environment Variables** tab ([below](#add-a-variable-on-coolify)) | Nothing. Coolify generates the secrets and the URL | -| `docker-compose.yml` at the repository root (ports published on the host) | `.env` next to the file | `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, and in practice `BETTER_AUTH_URL` and `CORS_ORIGIN` | -| Bank and email credentials, on any of the above | [The setup screen](#setup-screen-or-environment) at `/setup` | — | +| `deploy/docker-compose.yml` (Dokploy) | The panel's environment, or a `.env` next to the file | `FREENARY_URL`, `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET` | +| `deploy/coolify.yml` | The `server` service's `environment:` list ([below](#add-a-variable-on-coolify)) | Nothing: Coolify generates them | +| Root `docker-compose.yml` | A `.env` next to the file, which must exist | `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, and in practice `BETTER_AUTH_URL` and `CORS_ORIGIN` | -**`deploy/docker-compose.yml`.** `FREENARY_URL` is the one public URL of the instance, for example `https://freenary.example.com`. It fills `BETTER_AUTH_URL`, `CORS_ORIGIN` and `PUBLIC_SERVER_URL`, so the browser, the web app and the API server all use one address. The value must match the browser address bar exactly, scheme included, or every sign-in fails. +`FREENARY_URL`, or the domain of Coolify's `web` service, fills `BETTER_AUTH_URL`, `CORS_ORIGIN` and `PUBLIC_SERVER_URL` with one address. -**`deploy/coolify.yml`.** Coolify fills those same three variables from the domain you give the `web` service, and generates the database password and `BETTER_AUTH_SECRET`. +The `server` service reads every line of `.env`, but five keys the Compose file sets win: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN` and `NODE_ENV`. The `web` service reads no `.env` line. -**Root `docker-compose.yml`.** Compose reads the `.env` next to the file. The `server` service loads every line of it, but five keys in the compose file win over `.env`: +After an edit, run `docker compose up -d` or redeploy. A `restart` keeps the old values. -| Key on `server` | Value | -| --- | --- | -| `DATABASE_URL` | Built from `POSTGRES_PASSWORD`. A `DATABASE_URL` line of your own does nothing | -| `BETTER_AUTH_SECRET` | Your `.env` value | -| `BETTER_AUTH_URL` | Your `.env` value, default `http://localhost:3000` | -| `CORS_ORIGIN` | Your `.env` value, default `http://localhost:3001` | -| `NODE_ENV` | Always `production` | - -For one hostname, set `BETTER_AUTH_URL` and `CORS_ORIGIN` to the same URL. See [Reverse proxy](/docs/reference/reverse-proxy). - - - -The `web` service reads no `.env` line. It gets `SERVER_URL` (`http://server:3000`) and `PUBLIC_SERVER_URL`, which Compose copies from `BETTER_AUTH_URL`. To change the address the browser calls, change `BETTER_AUTH_URL`. - - - -`migrate` gets `DATABASE_URL` alone. `postgres` gets `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD`, and the first two are fixed in the compose file. - -After editing `.env`, apply it with `docker compose up -d`. Compose recreates the changed containers; no image rebuild is needed. +## Add a variable on Coolify -### Add a variable on Coolify - -Coolify passes a container only the variables its compose file names. `deploy/coolify.yml` names none of the optional ones, so a variable you only add under **Environment Variables**, such as `AI_BASE_URL` or `OIDC_DISCOVERY_URL`, never reaches the server. Open **Edit Compose File** and add one line per variable to the `environment:` list of the `server` service: +Coolify passes a container only the variables its Compose file names, and `deploy/coolify.yml` names none of the optional ones. Edit that file and add one line per variable to the `environment:` list of the `server` service: ```yaml - AI_BASE_URL=${AI_BASE_URL} - AI_MODEL=${AI_MODEL} ``` -Save. Coolify then lists each one under **Environment Variables**: fill in the values and redeploy. - -## Setup screen or environment - -The setup screen (`/setup`) is where the operator enters bank and email credentials. It owns these variables: +Save, set each value in the resource's environment-variable settings, and redeploy. -| Integration | Variables | -| --- | --- | -| Bank provider | `BANKING_PROVIDER`, `POWENS_DOMAIN`, `POWENS_CLIENT_ID`, `POWENS_CLIENT_SECRET`, `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY` | -| Email delivery | `EMAIL_PROVIDER`, `EMAIL_FROM`, `RESEND_API_KEY`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD` | - -Every other variable comes from the environment alone. A few must stay there, because Freenary needs them before it can serve the screen: `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, the public URL (`FREENARY_URL`, or `BETTER_AUTH_URL` and `CORS_ORIGIN`) and `FREENARY_VERSION`. - -### A value in the environment wins - -The server reads each variable from the environment first, and falls back to what the setup screen saved. An empty value counts as unset. - -- A variable set in the environment shows on the screen as read-only, tagged `Set by your server`. -- The screen shows the first four and last four characters of such a value, or `…` for a value of eight characters or fewer. It never shows a secret. `SMTP_PORT` and `SMTP_SECURE` show in full. -- `Save and continue` tests the environment values together with what you typed, and stores only the fields the environment doesn't set. -- When the environment alone configures a provider completely, the step shows a ready card with a `Test connection` button instead of the form. - -This lets you keep secrets in a `.env`, Kubernetes or Vault; the screen never fights it. To hand a value to the screen instead, delete its line from the environment and run `docker compose up -d`. - - - -Saving one bank provider on the setup screen clears the stored credentials of the other one. Connections made with the old provider then can't sync or disconnect. To switch providers and keep old connections working, put the old provider's credentials in the environment first. - - +## A value in the environment wins -### Where setup values are stored +The setup screen (`/setup`) can store 14 variables: `BANKING_PROVIDER`, `POWENS_DOMAIN`, `POWENS_CLIENT_ID`, `POWENS_CLIENT_SECRET`, `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY`, `EMAIL_PROVIDER`, `EMAIL_FROM`, `RESEND_API_KEY`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER` and `SMTP_PASSWORD`. Every other variable comes from the environment alone. -Each value is one row of the `instance_setting` table, sealed with AES-256-GCM under a key derived from `BETTER_AUTH_SECRET`. The row name is bound to the sealed value, so a row copied to another name won't open. The screen never sends a stored secret back to the browser; an empty secret field keeps the current value. +For each of the 14, a non-empty value in the environment wins over the stored one. The screen shows that field read-only, tagged `Set by your server`, never shows a secret, and masks a text or URL value to its first and last four characters. To hand a field to the screen, delete it from the environment and run `docker compose up -d`. -What follows from that: +The server reads stored values at startup only; `Finish setup` restarts it when one isn't in use yet. If the stored values fail validation together with the environment, the server ignores them and runs on the environment alone. -- A database dump contains the credentials. Protect it like the `.env` file. -- A new `BETTER_AUTH_SECRET` makes every stored value unreadable. The server logs one warning per row and turns that integration off until the operator enters it again. -- The server reads the stored values once, at startup. `Finish setup` restarts the server when a saved value isn't in use yet. That relies on the container restart policy: both Compose files set `restart: unless-stopped`, and `deploy/coolify.yml` relies on Coolify's own. If the server doesn't come back, see [Troubleshooting](/docs/run/troubleshooting#the-server-does-not-restart-after-setup). -- If the stored values fail validation at startup, the server ignores all of them, runs on the environment alone, and the setup screen shows `Configuration problem` with the reason. +Stored values are encrypted with `BETTER_AUTH_SECRET`. [Security](/docs/reference/security) says what a new secret breaks. ## Startup checks @@ -99,48 +49,26 @@ The API server checks its environment once, at startup. The schema is [`packages {/* env-sync:end */} -- An empty string counts as unset. `PORT=` is the same as no `PORT`, so the default applies. -- A bad value stops the process before it opens a port, and the server prints the variable that failed: - -```bash -❌ Invalid environment variables: [ - { - origin: "string", - code: "invalid_format", - format: "regex", - pattern: "/^\\d+$/u", - path: [ "PORT" ], - message: "PORT must be a decimal number between 1 and 65535", - } -] -error: Invalid environment variables -``` +An empty value counts as unset. A bad value stops the server before it opens a port, printing `❌ Invalid environment variables:` and the variable's `path` and `message`. -Never set `SKIP_ENV_VALIDATION` at runtime. It turns the whole schema off, so the server starts with a broken configuration. `apps/server/Dockerfile` sets it in the `builder` stage only, because a build has no database and no secret. +Never set `SKIP_ENV_VALIDATION` at runtime. Any non-empty value turns the whole schema off, and the server starts with a broken configuration. -### What stops a start +## What stops a start -| Mistake | Who refuses | Message | +| Variable | Refused by | Message | | --- | --- | --- | -| `POSTGRES_PASSWORD` or `BETTER_AUTH_SECRET` unset or empty | Compose, before any container | `Set POSTGRES_PASSWORD in .env`, `Set BETTER_AUTH_SECRET in .env` (`deploy/docker-compose.yml` words them slightly differently) | -| `FREENARY_URL` unset or empty | `deploy/docker-compose.yml` | `Set FREENARY_URL to the public URL of the web app, such as https://freenary.example.com` | -| No `.env` next to the root `docker-compose.yml` | Compose, on the `env_file` of `server` | Compose names the missing file | -| `FREENARY_VERSION` names a tag that doesn't exist | The image pull | `not found` | -| `BETTER_AUTH_SECRET` under 32 characters | The schema | The invalid-environment report, then exit | -| A value outside its enum, URL format or number range | The schema | The invalid-environment report, then exit | -| `NODE_ENV=production` with no `BETTER_AUTH_SECRET`, `DATABASE_URL`, `BETTER_AUTH_URL` or `CORS_ORIGIN` | The schema | The invalid-environment report. Those defaults apply outside production only | -| `TRUSTED_PROXIES` with an IPv4-mapped entry | The schema | `write an IPv4-mapped address in its IPv4 form, e.g. 10.0.0.0/24` | -| `EMAIL_PROVIDER=resend` with no key | The email registry | `EMAIL_PROVIDER=resend requires RESEND_API_KEY to be set.` | -| `EMAIL_PROVIDER=smtp` with no sender | The email registry | `EMAIL_PROVIDER=smtp requires EMAIL_FROM to be set.` | -| `AUTH_COOKIE_DOMAIN` with a trailing dot, a single label, or not a parent of both hostnames | The cookie policy | A message that names the value and the fix | -| Two hostnames, no `AUTH_COOKIE_DOMAIN`, and `BETTER_AUTH_URL` on plain HTTP | The cookie policy | `The API () and the web app () are on different hosts, so the session cookie needs SameSite=None — which browsers reject over plain HTTP. …` | -| The `instance_setting` table can't be read, for example the database is unreachable | The setup values loader, in production | The error, then exit | - -Outside production, the two-hostname cookie check is only a warning. The self-hosting Compose files set `production`, so a deployed instance always gets the refusal. `EMAIL_PROVIDER=resend` checks the key before the sender, and `smtp` checks the sender before the host, so a setup missing both reports the first. +| `POSTGRES_PASSWORD` or `BETTER_AUTH_SECRET` unset | Compose | `Set POSTGRES_PASSWORD in .env`, or similar | +| `FREENARY_URL` unset | `deploy/docker-compose.yml` | `Set FREENARY_URL to the public URL of the web app, …` | +| Any value outside its format, enum or range, such as a `BETTER_AUTH_SECRET` under 32 characters | The schema | The invalid-environment report | +| `BETTER_AUTH_SECRET`, `DATABASE_URL`, `BETTER_AUTH_URL` or `CORS_ORIGIN` unset in production | The schema | The invalid-environment report | +| `EMAIL_PROVIDER` without its other values | The email registry | `EMAIL_PROVIDER=resend requires RESEND_API_KEY to be set.` | +| `TRANSACTION_CLASSIFIER` without its URL or model | The classifier registry | `TRANSACTION_CLASSIFIER= requires TRANSACTION_CLASSIFIER_MODEL to be set.` | +| `AUTH_COOKIE_DOMAIN` wrong, or two hostnames over plain HTTP without it | The cookie policy | See [Two subdomains](/docs/reference/reverse-proxy#two-subdomains) | +| `DATABASE_URL` unreachable, in production | The setup values loader | `The instance configuration could not be read: …` | ## Core and secrets @@ -154,9 +82,7 @@ Outside production, the two-hostname cookie check is only a warning. The self-ho {/* env-sync:end */} -To change the host port, use `SERVER_PORT`, never `PORT`. - -A new `BETTER_AUTH_SECRET` signs everyone out, resets every two-factor enrolment and makes the setup values unreadable. See [Security](/docs/reference/security). +To change the host port, set `SERVER_PORT`, never `PORT`. ## Public URLs and cookies @@ -171,7 +97,7 @@ A new `BETTER_AUTH_SECRET` signs everyone out, resets every two-factor enrolment {/* env-sync:end */} -With one hostname, `AUTH_COOKIE_DOMAIN` stays unset. See [Reverse proxy](/docs/reference/reverse-proxy) for two subdomains and trusted proxies. +[Reverse proxy](/docs/reference/reverse-proxy) shows the values for one hostname and for two. ## Database @@ -179,15 +105,13 @@ With one hostname, `AUTH_COOKIE_DOMAIN` stays unset. See [Reverse proxy](/docs/r | Variable | Default | What it does | Absent or wrong | | --- | --- | --- | --- | -| `DATABASE_URL` | `postgresql://postgres:password@localhost:5432/freenary` outside production, required in production | The PostgreSQL connection string the Prisma driver adapter opens. | An empty value stops the start. An unreachable host fails at the first query, not at boot, because the driver connects late. In the Docker stack Compose builds this value from `POSTGRES_PASSWORD`. | +| `DATABASE_URL` | `postgresql://postgres:password@localhost:5432/freenary` outside production, required in production | The PostgreSQL connection string the Prisma driver adapter opens. | An empty value stops the start. An unreachable host stops it too, because the server reads the database at startup. In the Docker stack Compose builds this value from `POSTGRES_PASSWORD`. | {/* env-sync:end */} -No variable sets a pool size. See [Scaling](/docs/reference/scaling). - ## Email -Normally set on the setup screen. See [Email](/docs/run/email). +Usually set on the setup screen: see [Email](/docs/run/email). {/* env-sync:start email */} @@ -206,7 +130,7 @@ Normally set on the setup screen. See [Email](/docs/run/email). ## Sign-in -Each provider stays off until both of its values are set. See [Sign-in](/docs/run/sign-in) for redirect URLs. +See [Sign-in](/docs/run/sign-in) for redirect URLs. {/* env-sync:start sign-in */} @@ -228,7 +152,7 @@ Each provider stays off until both of its values are set. See [Sign-in](/docs/ru ## Bank providers -Normally set on the setup screen. See [Bank providers](/docs/reference/bank-providers). +Usually set on the setup screen: see [Bank providers](/docs/reference/bank-providers). {/* env-sync:start bank */} @@ -243,10 +167,12 @@ Normally set on the setup screen. See [Bank providers](/docs/reference/bank-prov {/* env-sync:end */} -An incomplete set isn't a startup error: Freenary reports no bank provider, and the bank step disappears. +An incomplete set doesn't stop the start: Freenary reports no bank provider. ## Assistant +Freenary ships without a model. To give every user one, point the instance at an OpenAI-compatible chat-completions endpoint. Only the environment configures it, not the setup screen. + {/* env-sync:start assistant */} | Variable | Default | What it does | Absent or wrong | @@ -257,22 +183,17 @@ An incomplete set isn't a startup error: Freenary reports no bank provider, and {/* env-sync:end */} -Any OpenAI-compatible chat-completions endpoint works. Freenary posts to `/chat/completions`, and the model must support tool calling, or it invents figures without reporting an error. - ```dotenv -# A hosted gateway AI_BASE_URL=https://openrouter.ai/api/v1 AI_API_KEY= AI_MODEL= ``` -```dotenv -# A runtime on your own server, as a service on the same Docker network -AI_BASE_URL=http://ollama:11434/v1 -AI_MODEL= -``` +On the same Docker network, use the model server's service name, such as `http://ollama:11434/v1`: `localhost` is the `server` container itself. + +Freenary posts to `/chat/completions`. The model must support tool calling, because it's told to state only figures a tool returned. -`http://localhost:11434/v1` would point at the API server container itself. See [Assistant](/docs/use/assistant) for what each question sends. +Each question sends that endpoint the data listed in [What data goes where](/docs/use/assistant#what-data-goes-where). ## Categorisation @@ -295,21 +216,15 @@ AI_MODEL= {/* env-sync:end */} -A classifier request carries `normalisedDescriptor`, `merchantKey`, `counterpartyName`, `merchantCategoryCode`, `direction`, `amountBucket`, `currency`, `country`, `channel` and `path`. It carries no exact amount, date, IBAN, account or user. For a transfer or direct debit, `merchantKey` is left out, because there it is the creditor account number. Answers are cached per merchant across the whole instance, so each merchant is asked about once. - -Leave `DICTIONARY_PUBLIC_KEY` unset. The published merchant dictionary carries no signature, so a set key makes the server refuse it, log `[categorisation] Dictionary signature missing: — refusing to load`, and stop matching merchants. The schema doesn't check the key's format, so nothing catches this at startup. Set it only when you sign a dictionary yourself (`cd packages/api && bun run generate:key`). +Leave `DICTIONARY_PUBLIC_KEY` unset unless you sign a dictionary yourself. The published merchant dictionary has no signature, so a set key makes the server refuse it and stop matching merchants, with no startup error. -## Logging - -No variable configures logs. In production, container output is the only destination. See [Troubleshooting](/docs/run/troubleshooting). - -## Not in the schema +## Read outside the schema -The code reads these straight from `process.env`, so a typo produces no startup error. +The code reads these from `process.env` directly, so a typo raises no startup error. {/* env-sync:start raw */} @@ -326,9 +241,7 @@ The code reads these straight from `process.env`, so a typo produces no startup {/* env-sync:end */} -`VERCEL_ENV`, `VERCEL_URL` and `VERCEL_PROJECT_PRODUCTION_URL` are read by the web app only when `VITE_SERVER_URL` is a relative path, which never happens in Docker. - -These two belong to the web app's client schema. Vite builds them into the bundle, so the image build decides their values: +The web app's client schema holds two more. The image build fixes their values: {/* env-sync:start client */} @@ -339,9 +252,9 @@ These two belong to the web app's client schema. Vite builds them into the bundl {/* env-sync:end */} -## Compose-only variables +## Compose variables -No application code reads these; they configure the containers. +Only Compose reads these. {/* env-sync:start compose */} @@ -356,7 +269,7 @@ No application code reads these; they configure the containers. {/* env-sync:end */} -The development stack reads a few more. The production files ignore them, and [the local stack guide](https://github.com/suiramdev/freenary/blob/main/docs/engineering/local-stack.md) covers them: +The development stack reads a few more, which the production files ignore: {/* env-sync:start compose-dev */} @@ -382,43 +295,18 @@ These appear in templates but do nothing in production: {/* env-sync:end */} -## Minimal files - -`deploy/docker-compose.yml`, in the panel's environment or a `.env` next to it: - -```dotenv -FREENARY_URL=https://freenary.example.com -FREENARY_VERSION=latest -POSTGRES_PASSWORD= -BETTER_AUTH_SECRET= -``` - -Root `docker-compose.yml`, behind your own proxy on one hostname: - -```dotenv -FREENARY_VERSION=latest -POSTGRES_PASSWORD= -BETTER_AUTH_SECRET= -BETTER_AUTH_URL=https://freenary.example.com -CORS_ORIGIN=https://freenary.example.com -SERVER_PORT=127.0.0.1:3000 -WEB_PORT=127.0.0.1:3001 -``` - -Add bank and email credentials on the setup screen after the first sign-in. - diff --git a/apps/fumadocs/content/docs/next/reference/from-source.mdx b/apps/fumadocs/content/docs/next/reference/from-source.mdx index 019107a..e97bd70 100644 --- a/apps/fumadocs/content/docs/next/reference/from-source.mdx +++ b/apps/fumadocs/content/docs/next/reference/from-source.mdx @@ -1,130 +1,86 @@ --- title: Build from source -description: Build the two images from a checkout, build for a fork, or run Freenary without Docker. +description: Build Freenary's two container images or run the server and web app directly. icon: Hammer --- -The published images suit most installs. Build your own when you've changed the code, run a fork, or can't pull from GitHub's container registry. Both published images already cover `linux/amd64` and `linux/arm64`. +Build from source when you need a local patch, a forked image or a non-Docker deployment. Use the repository root as the build context. -You need a clone of the repository, Docker Compose v2 with BuildKit, and network access during the build. The build reads no `.env` file: configuration arrives at runtime, as with the published images. +## Build the images -## Build with Compose - -In the root `docker-compose.yml`, replace each `image:` line with a `build:` block. `migrate` and `server` share the server image: - -```yaml -services: - migrate: - build: - context: . - dockerfile: apps/server/Dockerfile - server: - build: - context: . - dockerfile: apps/server/Dockerfile - web: - build: - context: . - dockerfile: apps/web/Dockerfile -``` +`apps/server/Dockerfile` and `apps/web/Dockerfile` are multi-stage Bun images. Both install the workspace. The server image starts the API server; Compose reuses it for the one-shot `migrate` service with a different command. ```bash -docker compose up -d --build +docker build \ + -f apps/server/Dockerfile \ + --build-arg GITHUB_REPOSITORY=suiramdev/freenary \ + -t freenary-server:local . + +docker build \ + -f apps/web/Dockerfile \ + --build-arg FREENARY_VERSION=main \ + -t freenary-web:local . ``` -The first build takes several minutes, mostly for the merchant dictionary. The build downloads the newest dictionary release and, if that fails, generates it from its sources, which takes much longer. With no network access, the build fails. - -## Build arguments - -Compose passes none of these; build the image by hand to set them. +The server build downloads the merchant dictionary from the newest `data-*` release. `GITHUB_REPOSITORY` selects the repository and defaults to `suiramdev/freenary`. -| Image | Argument | Default | Purpose | -| --- | --- | --- | --- | -| Server | `GITHUB_REPOSITORY` | `suiramdev/freenary` | Repository the merchant dictionary is downloaded from. Set it on a fork | -| Server | `GITHUB_TOKEN` (BuildKit secret) | none | Raises the GitHub API rate limit for that download. Never stored in a layer | -| Web | `FREENARY_VERSION` | empty | Release the account menu's documentation link opens. A value like `1.2.0` opens the `1.2` docs; anything else opens `next` | +An optional GitHub token raises the API rate limit. Pass it as a BuildKit secret, never as a build argument: ```bash -export GITHUB_TOKEN= -docker build -f apps/server/Dockerfile \ - --build-arg GITHUB_REPOSITORY=my-org/freenary \ +docker build \ + -f apps/server/Dockerfile \ --secret id=GITHUB_TOKEN,env=GITHUB_TOKEN \ - -t freenary-server . - -docker build -f apps/web/Dockerfile \ - --build-arg FREENARY_VERSION=1.2.0 \ - -t freenary-web . + -t freenary-server:local . ``` -Then use `image: freenary-server` on `migrate` and `server`, and `image: freenary-web` on `web`. - -## What the images contain - -| | Server | Web | -| ---------- | -------------------- | ------------------------------ | -| Base image | `oven/bun:1` | `oven/bun:1` | -| Port | `3000` | `3001` | -| Command | `bun dist/index.mjs` | `bun .output/server/index.mjs` | -| `NODE_ENV` | `production` | `production` | - -The server image keeps its development dependencies, which is why the `prisma` CLI is there and `migrate` can run from it. Neither image declares a `HEALTHCHECK` or a `USER`; the Compose files define the health checks. - -## Run without Docker - -For a server with its own PostgreSQL 18 and process supervisor. You need Bun `1.3.14`. - -1. Install dependencies: +The secret doesn't enter an image layer. If the release download fails, the build generates the dictionary from its upstream datasets. - ```bash - bun install - ``` +`FREENARY_VERSION` is compiled into the web image. A stable `X.Y.Z` value links to that documentation release; other values link to `next`. -2. Create `apps/server/.env`. The API server and the Prisma CLI both read it. Without Compose, set `NODE_ENV` yourself: +The repository `.dockerignore` excludes Git data, dependencies, build output, logs, compose files and every `.env` file except examples. - ```dotenv - NODE_ENV=production - BETTER_AUTH_SECRET= - BETTER_AUTH_URL=https://freenary.example.com - CORS_ORIGIN=https://freenary.example.com - DATABASE_URL=postgresql://freenary:@127.0.0.1:5432/freenary - ``` +Use the local server image for both `migrate` and `server`, and the local web image for `web`. Keep the runtime variables from the chosen compose file. -3. Apply the migrations, fetch the merchant dictionary and build: +A local build targets the Docker daemon's platform. Release CI builds `linux/amd64` and `linux/arm64` natively, then joins their digests. Use `docker buildx build --platform` when you need another platform or a multi-platform manifest. - ```bash - cd packages/db && bun x prisma migrate deploy && cd ../.. - bun run build:data - bun run build - ``` - -4. Start the API server: +## Run without Docker - ```bash - cd apps/server && bun run start - ``` +You need PostgreSQL, Bun `1.3.14`, and Node `^20.19`, `^22.12` or `>=24.0`. Node runs Prisma's install check; the applications run on Bun. -5. Start the web app in a second shell. `SERVER_URL` is where it forwards API calls; `PUBLIC_SERVER_URL` is the address the browser uses: +Create a PostgreSQL role and database that match `DATABASE_URL`. The migration creates tables, not the role or the database. Then create `apps/server/.env`: - ```bash - cd apps/web && HOST=127.0.0.1 PORT=3001 SERVER_URL=http://127.0.0.1:3000 PUBLIC_SERVER_URL=https://freenary.example.com bun .output/server/index.mjs - ``` +```dotenv +DATABASE_URL=postgresql://freenary:password@127.0.0.1:5432/freenary +BETTER_AUTH_SECRET=replace-with-at-least-32-characters +BETTER_AUTH_URL=https://freenary.example.com +CORS_ORIGIN=https://freenary.example.com +NODE_ENV=production +PORT=3000 +``` -Point your proxy at `127.0.0.1:3001`. See [Reverse proxy](/docs/reference/reverse-proxy). +Install, prepare the dictionary and database, then build both applications: -This isn't the contributor workflow; for that, see [Contribute](/docs/contribute). +```bash +bun install +bun run build:data +(cd packages/db && bun run db:migrate:deploy) +(cd apps/server && bun run build) +(cd apps/web && bun run build) +``` - +Run the processes separately: - +```bash +(cd apps/server && bun run start) + +(cd apps/web && \ + HOST=127.0.0.1 \ + PORT=3001 \ + SERVER_URL=http://127.0.0.1:3000 \ + PUBLIC_SERVER_URL=https://freenary.example.com \ + bun .output/server/index.mjs) +``` - +Put HTTPS in front of port 3001 and keep port 3000 private. The web process forwards the API paths to `SERVER_URL`; see [Reverse proxy](/docs/reference/reverse-proxy). - +Supervise both processes. When `Finish setup` applies stored settings, the server exits with status 0 after 250 milliseconds. Configure the supervisor to restart after a successful exit, not only after a failure. diff --git a/apps/fumadocs/content/docs/next/reference/glossary.mdx b/apps/fumadocs/content/docs/next/reference/glossary.mdx index c2cb7bb..8a10880 100644 --- a/apps/fumadocs/content/docs/next/reference/glossary.mdx +++ b/apps/fumadocs/content/docs/next/reference/glossary.mdx @@ -1,56 +1,52 @@ --- title: Glossary -description: The words Freenary and this documentation use, one line each. +description: The words the Freenary app shows, one line each. icon: BookA --- -Each term means the same thing on every page and in the app. +Each word means the same thing on every page and in the app. -## Your instance +## Instance and people -- **Instance**: your installed copy of Freenary, with its own users and database. -- **Operator**: the person who installs and runs the instance; the first account to sign in. -- **Setup screen**: `/setup`, where the operator enters bank and email credentials. -- **Your hostname**: the address of your instance, such as `freenary.example.com`. `FREENARY_URL` is its full `https://` URL. -- **Hosting panel**: a tool such as Dokploy or Coolify that runs Freenary's containers and adds HTTPS. -- **Bank provider**: the service that links Freenary to banks: Powens or Enable Banking. -- **Powens sandbox**: the free Powens test domain, with a test bank. - -## Your account - -- **Account**: your way into an instance: your name, email address and sign-in methods. -- **One-time code**: a 6-digit code Freenary emails you to confirm an address or reset a password. It's valid for 10 minutes. +- **Instance**: your installed copy of Freenary, with its own accounts and data. +- **Operator**: the first account on the instance, and the only one that can open the setup screen. +- **Setup**: the screen at `/setup` where the operator picks a **Bank provider** and an email service. +- **Tax residency**: the countries you pay tax in, picked once when you first sign in. They decide which banks you can connect. +- **Limited support**: a tax residency country Freenary supports only in part. See [Your account](/docs/use/account#first-steps). +- **Planned**: a sidebar item for a screen that doesn't exist yet. +- **First steps**: the checklist at the bottom of the sidebar until you've connected a bank, set a plan and protected your account. +- **One-time code**: a code Freenary emails you to confirm your address or reset your password. - **Passkey**: a key stored on your device, so you sign in with your device unlock instead of a password. -- **Two-factor authentication**: a code from your authenticator app on top of your password. Recovery codes replace a lost app. -- **Single sign-on**: signing in through an identity provider the operator connected. - -## Your banks - -- **Bank connection**: one link between Freenary and one of your banks, holding the accounts that bank shared. -- **Bank account**: one account in a bank connection, such as a current account, savings account or card. -- **Sync**: fetching new transactions from your bank into Freenary. -- **Transaction**: one entry your bank booked, with a date, an amount and the bank's description. -- **Company**: the other party of a transaction, as Freenary reads it from that description. -- **Internal transfer**: a pair of transactions moving money between two of your own accounts. Freenary matches and marks both. - -## Your budget - -- **Category**: the label a transaction carries, such as `Groceries`. -- **Category group**: one of the three fixed sections of the flow: `Income`, `Savings & investment` and `Spending`. -- **Custom category**: a category you create for budget lines, with your own name and icon. -- **Subcategory**: a custom category under another of your top-level custom categories. Only one level is allowed. -- **Budget line**: one monthly amount you plan for one category. -- **Budgeting profile**: all your budget lines together. -- **Period**: the date range the Budget screen shows: one month, three months or one year. -- **Aggregation**: how a multi-month period reports a figure: `Total`, `Monthly avg.` or `Monthly median`. -- **Fixed and variable spending**: spending that repeats, and the rest. -- **Other**: the category a transaction keeps until a rule, the merchant dictionary, a classifier or you decide one. - -## The assistant - -- **Assistant**: the question box on Home that answers questions about your money from your own data, using the model the operator configured. -- **Conversation**: one thread of questions and answers. `New conversation` files the current one away. - -## Money - -A transaction keeps the currency its bank booked it in. Totals and figures for a period are shown in euros, without currency conversion. +- **Two-factor authentication**: a code from your authenticator app on top of your password. A **Recovery code** replaces a lost app. + +## Banks + +- **Bank provider**: the service that links Freenary to banks, Powens or Enable Banking. +- **Bank accounts**: the card under Settings, `Connections`, that lists your connected banks and their accounts. +- **Connect**: start linking a bank. You sign in at your bank, then come back to Freenary. +- **Sync transactions**: fetch new transactions from your banks. Opening Budget does it too. +- **Disconnect**: remove a bank, its accounts and their transactions from Freenary. + +## Budget + +- **Transactions**: what came in (**Incoming**) and went out (**Outgoing**) over a period, by category. +- **Company**: the other party of a transaction, read from the bank's description. +- **Category**: the label a transaction carries, such as `Groceries`. **Other** is the one it gets when nothing fits. +- **Income, Savings & investment, Spending**: the three fixed groups every category sits in. +- **Custom category**: a category you create, with your own name and icon. +- **Budgeting profile**: your monthly plan, one amount per category. Each amount is a **budget line**. +- **Budget vs. actual**: your plan beside what you spent. +- **1M, 3M, 1Y**: the period Budget shows. On `3M` and `1Y`, **Total**, **Monthly avg.** and **Monthly median** change how figures are counted. +- **Fixed, Variable**: spending at companies Freenary sees as recurring, and the rest. +- **Recurring**: the page that lists payments that repeat over your last 12 months. +- **Commitments**: payments to one company on a regular schedule for a steady amount, such as rent. +- **Spending patterns**: purchases you repeat at one company without a regular schedule or amount. +- **Confidence**: how sure Freenary is of a recurring payment: **Confirmed**, **Likely** or **Pattern**. + +## Assistant + +- **Assistant**: the question box on Home that answers from your own data. +- **On your instance**: a model the operator set up for everyone. +- **On this device**: a model that runs in your browser on your graphics card. +- **New conversation**: start over. The previous conversation isn't shown again. +- **How the assistant answered**: the steps it took to find an answer. diff --git a/apps/fumadocs/content/docs/next/reference/procedures.mdx b/apps/fumadocs/content/docs/next/reference/procedures.mdx index 1ab1eb7..46dfb05 100644 --- a/apps/fumadocs/content/docs/next/reference/procedures.mdx +++ b/apps/fumadocs/content/docs/next/reference/procedures.mdx @@ -1,1057 +1,461 @@ --- title: Procedure reference -description: Every procedure of the Freenary API, with who can call it, its input bounds, its output and its errors. +description: Every procedure of the Freenary API, with who can call it, the input rules its schema can't express, its output and its errors. icon: ListTree --- -All 42 procedures, grouped by router. [API](/docs/reference/api) covers the base URL, the cookie and the request shapes. +All 42 procedures, grouped by router. [API](/docs/reference/api) covers the base URL, the cookie, the request shapes and the rate limits. - **Path.** `budget.getTransactions` is `POST /rpc/budget/getTransactions` or `POST /api-reference/budget/getTransactions`. -- **Access.** **Public** needs no session. **Protected** needs the session cookie, or returns `UNAUTHORIZED` (401). **Operator** also returns `FORBIDDEN` (403) to any account that isn't the operator. The 401 and 403 aren't repeated below. -- **Dates** are ISO 8601 strings. **Money** is an integer in minor units: 12.34 EUR is `1234`. +- **Input.** `/api-reference/spec.json` on your instance holds every input schema: types, required fields, enums, bounds and defaults. This page adds only the rules a schema can't express. +- **Access.** **Public** needs no session. **Protected** needs the session cookie, or returns `UNAUTHORIZED` (401). **Operator** also returns `FORBIDDEN` (403) to any account that isn't the operator. +- **Errors.** A procedure with no **Errors** line has no failure of its own. The 401, the 403, a failed schema and an unhandled `INTERNAL_SERVER_ERROR` can still happen. +- **Dates** are ISO 8601 strings. **Money** is an integer in minor units: 12.34 EUR is `1234`. Sums add stored amounts with no currency conversion, so a total over two currencies mixes them. -## Root procedures +## All procedures -### healthCheck +| Procedure | Access | Purpose | +| --- | --- | --- | +| [`healthCheck`](#healthcheck) | Public | Returns `"OK"` from the router | +| [`privateData`](#privatedata) | Protected | Returns the session user, to test a cookie | +| [`assistant.getConversation`](#assistantgetconversation) | Protected | The active conversation and the instance's model | +| [`assistant.saveTurn`](#assistantsaveturn) | Protected | Stores one finished turn the caller answered itself | +| [`assistant.startNewConversation`](#assistantstartnewconversation) | Protected | Archives the active conversation | +| [`auth.accountExists`](#authaccountexists) | Public | Whether an email address holds an account | +| [`auth.capabilities`](#authcapabilities) | Public | The sign-in methods and password rules of the instance | +| [`auth.viewer`](#authviewer) | Public | Whether the caller is signed in and onboarded | +| [`bankConnection.disconnect`](#bankconnectiondisconnect) | Protected | Deletes one bank connection and everything under it | +| [`bankConnection.exchangeCode`](#bankconnectionexchangecode) | Protected | Turns the bank provider callback into a bank connection | +| [`bankConnection.getProviderAvailability`](#bankconnectiongetprovideravailability) | Protected | Whether a bank provider is configured | +| [`bankConnection.listConnections`](#bankconnectionlistconnections) | Protected | The caller's bank connections and their accounts | +| [`bankConnection.listInstitutions`](#bankconnectionlistinstitutions) | Protected | The banks the provider can link in some countries | +| [`bankConnection.startConnection`](#bankconnectionstartconnection) | Protected | Opens a bank consent session | +| [`budget.getAccounts`](#budgetgetaccounts) | Protected | The caller's bank accounts and transaction date bounds | +| [`budget.getBudgetVsActual`](#budgetgetbudgetvsactual) | Protected | Planned against real spend per category | +| [`budget.getCashFlow`](#budgetgetcashflow) | Protected | Money in and out per day, week or month | +| [`budget.getFixedVsVariable`](#budgetgetfixedvsvariable) | Protected | Money out split into recurring and the rest | +| [`budget.getMerchants`](#budgetgetmerchants) | Protected | The companies of a date range, most frequent first | +| [`budget.getRecurring`](#budgetgetrecurring) | Protected | Everything the Recurring page reads | +| [`budget.getRecurringExpenses`](#budgetgetrecurringexpenses) | Protected | The merchants detected as regular | +| [`budget.getSankeyData`](#budgetgetsankeydata) | Protected | The income-to-category flow | +| [`budget.getSpendingBreakdown`](#budgetgetspendingbreakdown) | Protected | Money out per category | +| [`budget.getSyncStatus`](#budgetgetsyncstatus) | Protected | The sync or recategorisation in flight | +| [`budget.getTransactions`](#budgetgettransactions) | Protected | One page of transactions, with the range totals | +| [`budget.recategorise`](#budgetrecategorise) | Protected | Reruns the categorisation pipeline | +| [`budget.syncAccounts`](#budgetsyncaccounts) | Protected | Pulls fresh data from the bank provider | +| [`budget.updateTransactionCategory`](#budgetupdatetransactioncategory) | Protected | Sets one transaction's category and remembers the merchant | +| [`instance.status`](#instancestatus) | Public | The setup state | +| [`instance.describe`](#instancedescribe) | Operator | Each integration and where its values come from | +| [`instance.check`](#instancecheck) | Operator | Tests credentials without saving them | +| [`instance.save`](#instancesave) | Operator | Stores setup values | +| [`instance.complete`](#instancecomplete) | Operator | Finishes setup | +| [`onboarding.completeOnboarding`](#onboardingcompleteonboarding) | Protected | Stores the tax countries and ends the first-run flow | +| [`onboarding.getStatus`](#onboardinggetstatus) | Protected | Whether the first-run flow is done | +| [`settings.createCustomCategory`](#settingscreatecustomcategory) | Protected | Creates a custom category | +| [`settings.deleteCustomCategory`](#settingsdeletecustomcategory) | Protected | Deletes a custom category and rehomes its budget lines | +| [`settings.getBudgetProfile`](#settingsgetbudgetprofile) | Protected | The caller's budget lines | +| [`settings.listCategories`](#settingslistcategories) | Protected | The category tree, in display order | +| [`settings.moveCustomCategory`](#settingsmovecustomcategory) | Protected | Swaps a custom category with its neighbour | +| [`settings.saveBudgetProfile`](#settingssavebudgetprofile) | Protected | Replaces every budget line | +| [`settings.updateCustomCategory`](#settingsupdatecustomcategory) | Protected | Rewrites a custom category | + +## Date ranges and aggregation + +`from` and `to` are both included. Nothing checks that `from` comes before `to`. + +Four procedures take `aggregation`: `getBudgetVsActual`, `getFixedVsVariable`, `getSankeyData` and `getSpendingBreakdown`. `total` sums the range. `average` and `median` work on monthly sums, over the months that hold at least one transaction the procedure reads, so an empty month doesn't count as zero. -Public. Returns a fixed string, so a caller can probe the router itself. +## Root procedures -**Input**: none. +### healthCheck **Output**: the string `"OK"`. -**Errors**: none. - ### privateData -Protected. Returns a fixed string and the Better Auth user of the session. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --------- | --------------------- | --------------------------------------- | -| `message` | string | Always the literal `"This is private"`. | -| `user` | object or `undefined` | The raw Better Auth user record. | - -**Errors**: none. - -The web app never calls it. Use it to check that your cookie works. +**Output**: `{ message: "This is private", user }`, where `user` is the raw Better Auth user record. ## `assistant` ### assistant.getConversation -Protected. Returns the active conversation of the caller, and the model of the instance. - -**Input**: none. +**Output**: `{ conversationId, messages, serverModel }`. -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `conversationId` | string | The id of the active conversation. The read creates one when the caller has none. | -| `messages` | array of `{ id, parts, role }` | The stored transcript, oldest first. An archived conversation never appears. | -| `serverModel` | string or `null` | The model id of the instance, and `null` when the instance holds none. | - -| Field of one message | Type | Note | -| --- | --- | --- | -| `id` | string | The row id. Pass it as `messageId` to redo that turn. | -| `parts` | array | The `UIMessage.parts` array of the AI SDK, as the stream route stored it. | -| `role` | `"user"` or `"assistant"` | The two stored roles. | - -**Errors**: none. +- `conversationId` names the active conversation. The read creates one when the caller has none. +- `messages` is the stored transcript, oldest first, as `{ id, parts, role }`. `role` is `user` or `assistant`, `parts` is the AI SDK `UIMessage.parts` array, and `id` is what you pass as `messageId` to redo that turn. Archived conversations never appear. +- `serverModel` is the model id of the instance, or `null` when it has none. ### assistant.saveTurn -Protected. Stores one finished turn that the caller answered itself. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `answer.id` | string | yes | A UUID. It becomes the id of the answer row. | -| `answer.parts` | array | yes | At least one part. A part is a text part, a reasoning part, a step-start part or a tool part. | -| `conversationId` | string | yes | Must name the active conversation of the caller. | -| `question` | array of `{ text, type: "text" }` | yes | At least one part. Text parts only. | -| `regeneratedMessageId` | string | no | The id of the stored answer this turn replaces. | - -**Output** +**Input**: `conversationId` must name the caller's active conversation. `answer.id` becomes the id of the answer row. `regeneratedMessageId` names the stored answer this turn replaces. -| Field | Type | Note | -| ----- | ------- | -------------- | -| `ok` | boolean | Always `true`. | +**Output**: `{ ok: true }`. -**Errors** +**Errors**, in the order they're checked: -- `TOO_MANY_REQUESTS` (429) — over 30 turns per 300 seconds, shared with `POST /ai/chat`. Checked first. -- `BAD_REQUEST` (400) `Turn too large` — the question exceeds 8000 characters, or the answer parts exceed 200000 characters as JSON. +- `TOO_MANY_REQUESTS` (429): over the [rate limit](/docs/reference/api#rate-limits). +- `BAD_REQUEST` (400) `Turn too large`: the question exceeds 8000 characters, or the answer parts exceed 200000 characters as JSON. - `NOT_FOUND` (404) `Conversation is not the active one`. -- `BAD_REQUEST` (400) `Empty answer` — no text part and no resolved tool part survived the filter. +- `BAD_REQUEST` (400) `Empty answer`: no text part and no resolved tool part survived the filter. ### assistant.startNewConversation -Protected. Archives the active conversation of the caller, and deletes nothing. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| ----- | ------- | -------------- | -| `ok` | boolean | Always `true`. | - -**Errors**: none. +**Output**: `{ ok: true }`. Nothing is deleted. ## `auth` ### auth.accountExists -Public. Tells a caller whether an address already holds an account. - -**Input** +**Output**: `{ exists }`. The lookup folds the address to lower case first. -| Field | Type | Required | Bounds | -| ------- | ------ | -------- | ------------------------------- | -| `email` | string | yes | Must parse as an email address. | - -**Output** - -| Field | Type | Note | -| -------- | ------- | ------------------------------------------------- | -| `exists` | boolean | The lookup folds the address to lower case first. | - -**Errors** - -- `TOO_MANY_REQUESTS` (429) — over 20 calls per 60 seconds per caller address. -- `BAD_REQUEST` (400) `Input validation failed` — the address does not parse. +**Errors**: `TOO_MANY_REQUESTS` (429), over the [rate limit](/docs/reference/api#rate-limits). ### auth.capabilities -Public. Describes the sign-in doors this instance opens. - -**Input**: none. - **Output** -| Field | Type | Note | -| --- | --- | --- | -| `emailDelivery` | boolean | `true` only when the operator set an email provider. | -| `maxPasswordLength` | number | The password ceiling. | -| `minPasswordLength` | number | The password floor. | -| `oauth` | array of `{ id, name }` | One entry per configured provider. Empty with none. | -| `otpLength` | number | The digit count of a one-time code. | -| `passkey` | boolean | Always `true`. | -| `sessionCookieShared` | boolean | `true` when the API server and the web app share the session cookie. | -| `trustedDeviceDays` | number | The trusted-device window in days. | - -**Errors**: none. +| Field | Note | +| --- | --- | +| `emailDelivery` | `true` only when an email provider is set | +| `minPasswordLength`, `maxPasswordLength` | The password bounds | +| `oauth` | One `{ id, name }` per configured sign-in provider | +| `otpLength` | Digits in a one-time code | +| `passkey` | Always `true` | +| `sessionCookieShared` | `true` when the API server and the web app share the session cookie | +| `trustedDeviceDays` | The trusted-device window, in days | ### auth.viewer -Public. Names the caller. A caller with no session gets a `guest` answer, and not a 401. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `kind` | `"guest"` or `"member"` | `member` when the request carries a session. | -| `onboarded` | boolean | On a `member` answer only. `true` once the caller finished the first-run flow. | -| `sessionCookieShared` | boolean | The same value `auth.capabilities` reports. | +A caller with no session gets a `guest` answer, not a 401. -**Errors**: none. +**Output**: `{ kind, onboarded, sessionCookieShared }`. `kind` is `guest` or `member`. `onboarded` appears on a `member` answer only. `sessionCookieShared` is the value `auth.capabilities` reports. ## `bankConnection` ### bankConnection.disconnect -Protected. Deletes one bank connection and every row under it. - -**Input** - -| Field | Type | Required | Bounds | -| -------------- | ------ | -------- | -------------------------- | -| `connectionId` | string | yes | Must belong to the caller. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `accountsRemoved` | number | Bank accounts the delete removed. | -| `institutionName` | string | The bank name of the deleted connection. | -| `revocationRequested` | boolean | `false` when the provider refused the revoke call, or when no provider is configured. | +**Input**: `connectionId` must belong to the caller. -**Errors** +**Output**: `{ accountsRemoved, institutionName, revocationRequested }`. `revocationRequested` is `false` when the provider refused the revoke call or has no credentials; the connection is deleted either way. -- `NOT_FOUND` (404) `Bank connection not found`. +**Errors**: `NOT_FOUND` (404) `Bank connection not found`. ### bankConnection.exchangeCode -Protected. Turns the bank provider callback query into a bank connection. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `params` | object of string to string | yes | The raw callback query. It must hold a `state` key. | -| `providerId` | string | yes | `powens` or `enable-banking`. Any other value gets `Unknown banking provider`. | - -**Output** +**Input**: `params` is the raw callback query and must hold a `state` key. `providerId` is `powens` or `enable-banking`. -| Field | Type | Note | -| --- | --- | --- | -| `accounts` | array of `{ iban?, name?, uid }` | The accounts the bank provider returned. `iban` and `name` can be absent. | -| `connectionId` | string | The new bank connection. | -| `institutionName` | string | The bank name, trimmed. | -| `returnTo` | `"onboarding"` or `"settings"` | The target the caller signed into the state. | -| `sessionId` | string | The bank provider session id. | +**Output**: `{ accounts, connectionId, institutionName, returnTo, sessionId }`. `accounts` lists `{ iban?, name?, uid }` as the provider returned them. `returnTo` is `onboarding` or `settings`, as signed into the state. `sessionId` is the provider's session id. **Errors** - `NOT_FOUND` (404) `Unknown banking provider`. -- `BAD_REQUEST` (400) `Missing bank connection state`. -- `BAD_REQUEST` (400) `Invalid bank connection state`. -- `BAD_REQUEST` (400) `Invalid banking provider in connection state`. -- `BAD_REQUEST` (400) `Bank connection state does not belong to this session`. -- `BAD_REQUEST` (400) `Bank institution is no longer available`. +- `BAD_REQUEST` (400) `Missing bank connection state`, `Invalid bank connection state`, `Invalid banking provider in connection state`, `Bank connection state does not belong to this session`, `Bank institution is no longer available`, or `No provider session for this user`. - `SERVICE_UNAVAILABLE` (503) `Could not load banks for this country`. -- `BAD_REQUEST` (400) `No provider session for this user`. ### bankConnection.getProviderAvailability -Protected. Reports whether the operator configured the bank provider. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| ----------- | ------- | ------------------------------------------------- | -| `available` | boolean | `false` when the provider credentials are absent. | - -**Errors**: none. +**Output**: `{ available }`, `false` when the provider credentials are absent. ### bankConnection.listConnections -Protected. Lists the bank connections of the caller and their bank accounts, oldest first. - -**Input**: none. - -**Output**: `{ connections: [...] }`. - -| Field of one connection | Type | Note | -| --- | --- | --- | -| `accounts` | array of `{ iban, id, name }` | Oldest first. | -| `id` | string | Pass it as `connectionId` to `disconnect` or to `budget.syncAccounts`. | -| `institutionCountry` | string or `null` | The country code of the bank. `null` when the provider gave none. | -| `institutionId` | string | The bank provider id of the bank. | -| `institutionName` | string | The bank name. | -| `lastSyncedAt` | date or `null` | `null` before the first sync. | -| `status` | string | The connection status value. | +**Output**: `{ connections }`, oldest first. Each connection is: -**Errors**: none. +| Field | Note | +| --- | --- | +| `accounts` | `{ iban, id, name }`, oldest first | +| `id` | Pass it as `connectionId` to `disconnect` or `budget.syncAccounts` | +| `institutionCountry` | The bank's country code, or `null` | +| `institutionId`, `institutionName` | The bank, as the provider names it | +| `lastSyncedAt` | `null` before the first sync | +| `status` | `ACTIVE`, `EXPIRED` or `ERROR`. Only `ACTIVE` connections sync | ### bankConnection.listInstitutions -Protected. Lists the banks the provider can link in one country or more. The provider answers one country at a time, so the result is the union of the answers. A bank is identified by the pair of `id` and `country`. The same `id` in two countries is two banks here, and a duplicate pair is dropped. A country whose call fails is left out of `banks` and named in `unavailableCountries`. - -**Input** - -| Field | Type | Required | Default | Bounds | -| --- | --- | --- | --- | --- | -| `countries` | array of string | no | The caller's tax countries (also when empty) | Uppercase ISO 3166-1 alpha-2 codes, 10 at most. Duplicates are removed. | +**Input**: leave `countries` out, or send `[]`, to use the caller's tax countries. -**Output**: `{ banks: [...], unavailableCountries: [...] }`. +**Output**: `{ banks, unavailableCountries }`. A bank is `{ bic, country, id, logo, name }`; `bic` and `logo` can be `null`. Pass `id` as `institutionId` to `startConnection`. -| Field of one bank | Type | Note | -| --- | --- | --- | -| `bic` | string or `null` | The bank identifier code. | -| `country` | string | The country code of the bank. | -| `id` | string | Pass it as `institutionId` to `startConnection`. | -| `logo` | string or `null` | A logo URL. | -| `name` | string | The display name of the bank. | - -`unavailableCountries` holds the requested country codes whose provider call failed. A country that returned no bank is absent from it. - -**Errors** +The provider answers one country at a time and the result is the union. A bank is the pair of `id` and `country`, and a duplicate pair is dropped. `unavailableCountries` lists the countries whose provider call failed. A country with no bank isn't listed there. -- `BAD_REQUEST` (400) `No country to list banks for` — no input country, and the account holds none. +**Errors**: `BAD_REQUEST` (400) `No country to list banks for`, when the input and the account both hold no country. ### bankConnection.startConnection -Protected. Opens a bank consent session and returns the URL to send the user to. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `bankCountry` | string | yes | An uppercase ISO 3166-1 alpha-2 code. It must match the country of the institution. | -| `institutionId` | string | yes | An `id` from `listInstitutions`. | -| `returnTo` | enum | yes | `onboarding` or `settings`. | -| `state` | string | no | Your own value. It comes back in the callback state. | - -**Output** - -| Field | Type | Note | -| ----- | ------ | ---------------------------------- | -| `url` | string | Send the browser of the user here. | - -**Errors** +**Input**: `institutionId` is an `id` from `listInstitutions`, and `bankCountry` must match its country. `state` is your own value, returned in the callback state. -- `BAD_REQUEST` (400) `Unknown bank institution`. -- `SERVICE_UNAVAILABLE` (503) `Could not load banks for this country`. +**Output**: `{ url }`. Send the user's browser there. The provider redirects back to `CORS_ORIGIN` plus `/callback/powens` or `/callback/enable-banking`, a page of the web app. -The redirect URL is `CORS_ORIGIN` plus `/callback/powens` or `/callback/enable-banking`, a page of the web app. Don't route `/callback` to the API server. +**Errors**: `BAD_REQUEST` (400) `Unknown bank institution`; `SERVICE_UNAVAILABLE` (503) `Could not load banks for this country`. ## `budget` ### budget.getAccounts -Protected. Lists the bank accounts of the caller and the date bounds of their transactions. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `accounts` | array of `{ iban, id, institutionName, name }` | Every bank account of the caller. | -| `firstTransactionDate` | date or `null` | The oldest transaction date, `null` with none. | -| `hasAccounts` | boolean | `true` with at least one bank account. | -| `lastTransactionDate` | date or `null` | The newest transaction date, `null` with none. | - -**Errors**: none. +**Output**: `{ accounts, firstTransactionDate, hasAccounts, lastTransactionDate }`. `accounts` holds `{ iban, id, institutionName, name }` in no set order. The two dates bound the caller's transactions, and are `null` when there are none. ### budget.getBudgetVsActual -Protected. Compares the planned amount per spending category with the real spend. - -**Input** - -| Field | Type | Required | Default | Bounds | -| ------------- | ---- | -------- | ------- | ------------------------------- | -| `aggregation` | enum | no | `total` | `total`, `average` or `median`. | -| `from` | date | yes | — | No minimum, and no maximum. | -| `to` | date | yes | — | No check against `from`. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `categories` | array of `{ actual, category, planned }` | `category` is a [spending category](#spending-categories). A row lands here when the plan or the spend is not zero. Largest planned amount first, then largest actual. | -| `hasPlan` | boolean | `false` when the caller saved no outgoing budget line. | - -With `aggregation: "total"`, `planned` is the monthly plan times the months in the range. +**Output**: `{ categories, hasPlan }`. `categories` holds `{ actual, category, planned }` for each category whose plan or spend isn't zero, largest planned first, then largest actual. `hasPlan` is `false` with no outgoing budget line. -**Errors**: none. +With `average` or `median`, `planned` is the monthly plan. With `total`, it's the monthly plan times the months from `from` to `to` or today, whichever comes first: average-length months, rounded, at least 1. ### budget.getCashFlow -Protected. Sums money in and money out per period. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `from` | date | yes | The range includes this date. | -| `to` | date | yes | The range includes this date. No check against `from`. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `periods` | array of `{ incoming, label, outgoing }` | Oldest first. `label` is a day (`YYYY-MM-DD`) for ranges up to 31 days, a week's Monday up to 93 days, and a month (`YYYY-MM`) beyond. | - -**Errors**: none. +**Output**: `{ periods }` of `{ incoming, label, outgoing }`, oldest first. `label` is a day (`YYYY-MM-DD`) for ranges up to 31 days, the Monday of a week up to 93 days, and a month (`YYYY-MM`) beyond. ### budget.getFixedVsVariable -Protected. Splits money out into a recurrent part and the rest. - -**Input** - -| Field | Type | Required | Default | Bounds | -| ------------- | ---- | -------- | ------- | ------------------------------- | -| `aggregation` | enum | no | `total` | `total`, `average` or `median`. | -| `from` | date | yes | — | The range includes this date. | -| `to` | date | yes | — | No check against `from`. | - -**Output** - -| Field | Type | Note | -| ---------- | ------ | -------------------------------------- | -| `fixed` | number | Minor units. | -| `variable` | number | Minor units. The two sum to the total. | - -**Errors**: none. +**Output**: `{ fixed, variable }`. The two sum to the money out, aggregated as asked. ### budget.getMerchants -Protected. Lists the companies the transactions of a date range name, most frequent first. - -**Input** - -| Field | Type | Required | Default | Bounds | -| --- | --- | --- | --- | --- | -| `direction` | enum | no | — | `incoming` or `outgoing`. | -| `from` | date | yes | — | The range includes this date. | -| `limit` | number | no | `30` | 1 to 100. | -| `search` | string | no | — | A case-insensitive substring of the company name. | -| `to` | date | yes | — | No check against `from`. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `merchants` | array of `{ count, name, totalMinor }` | Highest count first, then the name. `totalMinor` sums the absolute amounts. | +**Input**: `search` is matched with SQL `ILIKE`, so `%` and `_` in it act as wildcards. -**Errors**: none. +**Output**: `{ merchants }` of `{ count, name, totalMinor }`, highest count first, then by name. `totalMinor` sums absolute amounts. ### budget.getRecurring -Protected. Returns everything the Recurring page of the Budget screen reads, in one call. The detection rules behind `confidence` and `kind` are in [`docs/engineering/categorisation.md`](https://github.com/suiramdev/freenary/blob/main/docs/engineering/categorisation.md#recurrence-detection). - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `asOf` | string | ISO 8601 date-time. When the detection ran. Every projection counts from here. | -| `availableBalanceMinor` | number or `null` | Minor units. The sum of every account balance, and `null` when no account reports one. | -| `currency` | string | The most common account currency. `EUR` when no account reports one. | -| `items` | array | One detected pattern per entry. | -| `monthly` | array | Exactly the trailing 12 calendar months, oldest first, zero-filled. | -| `monthlyIncomeMinor` | number or `null` | Minor units. The window's incoming sum divided by the months from its first incoming transaction, and `null` when none came in. A short history is therefore not divided by 12. | -| `plannedOutgoingMinor` | number or `null` | Minor units. The monthly sum of the outgoing budget lines, and `null` with no plan. | - -| Field of one item | Type | Note | -| --- | --- | --- | -| `amountSpread` | number | The relative spread of the amounts. `0` when they are all the same. | -| `category` | string | A [spending category](#spending-categories) slug. | -| `confidence` | enum | `confirmed`, `likely` or `pattern`. | -| `currency` | string | The ISO 4217 code of the most recent occurrence. | -| `frequency` | enum | `weekly`, `monthly`, `quarterly`, `annual` or `irregular`. | -| `intervalDays` | number | The median day gap between two occurrences. | -| `kind` | enum | `fixed` or `behavioral`. | -| `lastSeen` | string | ISO 8601 date-time. | -| `merchantKey` | string | The lookup key of the merchant. | -| `merchantName` | string or `null` | The counterparty name of the most recent occurrence. | -| `nextExpected` | string | ISO 8601 date-time. | -| `occurrences` | number | The transaction count inside the trailing year of `asOf`. | -| `typicalAmountMinor` | number | Minor units, positive. | - -| Field of one month | Type | Note | -| --- | --- | --- | -| `behavioralMinor` | number | Minor units. The part of the month the repeated purchases hold. | -| `discretionaryMinor` | number | Minor units. The rest of the money out of that month. | -| `fixedMinor` | number | Minor units. The part of the month the commitments hold. | -| `month` | string | `YYYY-MM`. The month number is 1-based. | - -**Errors**: none. +The rules behind `confidence` and `kind` are in [`docs/engineering/categorisation.md`](https://github.com/suiramdev/freenary/blob/dev/docs/engineering/categorisation.md#recurrence-detection). + +**Output** + +| Field | Note | +| --- | --- | +| `asOf` | When the detection ran. Every projection counts from here | +| `availableBalanceMinor` | The sum of account balances, or `null` when no account reports one | +| `currency` | The most common account currency, else `EUR` | +| `items` | One detected pattern per entry | +| `monthly` | The trailing 12 calendar months, oldest first, zero-filled: `{ month, fixedMinor, behavioralMinor, discretionaryMinor }`, `month` as `YYYY-MM` | +| `monthlyIncomeMinor` | Money in over the window, divided by the months since the first incoming transaction, or `null` | +| `plannedOutgoingMinor` | The monthly sum of outgoing budget lines, or `null` with no plan | + +| Field of one item | Note | +| --- | --- | +| `amountSpread` | Relative spread of the amounts. `0` when all are equal | +| `category` | A spending category slug | +| `confidence` | `confirmed`, `likely` or `pattern` | +| `currency` | ISO 4217 code of the latest occurrence | +| `frequency` | `weekly`, `monthly`, `quarterly`, `annual` or `irregular` | +| `intervalDays` | Median gap between occurrences, in days | +| `kind` | `fixed` or `behavioral` | +| `lastSeen`, `nextExpected` | ISO 8601 date-times | +| `merchantKey`, `merchantName` | The merchant lookup key, and the latest counterparty name or `null` | +| `occurrences` | Transactions in the year before `asOf` | +| `typicalAmountMinor` | Positive | ### budget.getRecurringExpenses -Protected. Lists the merchants the recurrence detector holds as regular. - -**Input**: none. - -**Output**: `{ expenses: [...] }`, biggest typical amount first. - -| Field of one expense | Type | Note | -| --- | --- | --- | -| `category` | string | A [spending category](#spending-categories) slug. | -| `currency` | string | The ISO 4217 code of the most recent occurrence. | -| `frequency` | enum | `weekly`, `monthly`, `quarterly`, `annual` or `irregular`. | -| `intervalDays` | number | The median day gap between two occurrences. | -| `lastSeen` | string | ISO 8601. | -| `merchantKey` | string | The lookup key of the merchant. | -| `merchantName` | string or `null` | The counterparty name of the most recent occurrence. | -| `nextExpected` | string | ISO 8601. | -| `occurrences` | number | The transaction count inside the 365-day window. | -| `typicalAmountMinor` | number | Minor units. | - -**Errors**: none. +**Output**: `{ expenses }`, largest typical amount first. Each entry has the fields of a `getRecurring` item except `amountSpread`, `confidence` and `kind`. `occurrences` counts inside a 365-day window. ### budget.getSankeyData -Protected. Returns the nodes of the income-to-category flow. - -**Input** - -| Field | Type | Required | Default | Bounds | -| ------------- | ---- | -------- | ------- | ------------------------------- | -| `aggregation` | enum | no | `total` | `total`, `average` or `median`. | -| `from` | date | yes | — | The range includes this date. | -| `to` | date | yes | — | No check against `from`. | +**Output**: `{ groups, incomeNodes, moneyLeft, totalExpenses, totalIncome }`. -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `groups` | array of `{ categories, group, value }` | `categories` holds `{ category, value }` rows. | -| `incomeNodes` | array of `{ name, value }` | The 10 largest sources. The rest fold into the literal name `Other Income`. | -| `moneyLeft` | number | `totalIncome` minus `totalExpenses`, never below 0. | -| `totalExpenses` | number | The sum of every group value. | -| `totalIncome` | number | The sum of every income node value. | +- `groups` holds `{ categories, group, value }`, with `{ category, value }` rows in `categories`. +- `incomeNodes` holds `{ name, value }`: the 10 largest sources by counterparty name, then `Other Income`. Money in with no counterparty name joins `Other Income` before the top 10 are picked, so `Other Income` can rank among them. +- `moneyLeft` is `totalIncome` minus `totalExpenses`, never below 0. -**Errors**: none. +Internal transfers count as income and as spending. ### budget.getSpendingBreakdown -Protected. Sums money out per spending category. - -**Input** - -| Field | Type | Required | Default | Bounds | -| ------------- | ---- | -------- | ------- | ------------------------------- | -| `aggregation` | enum | no | `total` | `total`, `average` or `median`. | -| `from` | date | yes | — | The range includes this date. | -| `to` | date | yes | — | No check against `from`. | - -**Output** - -| Field | Type | Note | -| ------------ | ------------------------------- | --------------------- | -| `categories` | array of `{ amount, category }` | Largest amount first. | - -**Errors**: none. +**Output**: `{ categories }` of `{ amount, category }`, largest first. ### budget.getSyncStatus -Protected. Reports the sync the caller has in flight, and `null` when none runs. - -**Input**: none. - -**Output**: `null`, or one object. +**Output**: `null` when nothing runs, or `{ accountsDone, accountsTotal, categorised, categorisedTotal, phase, startedAt, transactionsImported }`. `phase` is `importing` or `categorising`. `categorisedTotal` is `0` until categorising starts. `transactionsImported` counts new and updated rows alike. -| Field | Type | Note | -| --- | --- | --- | -| `accountsDone` | number | Accounts read so far. | -| `accountsTotal` | number | Accounts the run will read. | -| `categorised` | number | Transactions the phase has decided so far, filed or left uncategorised. | -| `categorisedTotal` | number | Transactions the phase has to decide. `0` until it starts. | -| `phase` | enum | `importing` or `categorising`. | -| `startedAt` | date | When the run claimed the user. | -| `transactionsImported` | number | Transactions written so far, new and updated alike. | - -**Errors**: none. - -Poll it while a sync runs: it reads one row and calls no provider. Counters update at most once a second, and a run silent for five minutes reads as `null`. +Poll it during a sync: it reads one row and calls no provider. Counters update at most once a second. A run silent for 5 minutes reads as `null`. ### budget.getTransactions -Protected. The one paged procedure. Returns transactions in a date range, with the range totals. +The one paged procedure: pass `nextCursor` back as `cursor`. **Input** -| Field | Type | Required | Default | Bounds | -| --- | --- | --- | --- | --- | -| `amountMax` | number | no | — | An integer of 0 or more, in minor units. It reads the absolute amount. | -| `amountMin` | number | no | — | An integer of 0 or more, in minor units. It reads the absolute amount. | -| `categories` | array of enum | no | — | Items from the 27 [spending categories](#spending-categories). | -| `cursor` | string | no | — | A `nextCursor` from an earlier call. | -| `direction` | enum | no | — | `incoming` or `outgoing`. | -| `from` | date | yes | — | The range includes this date. | -| `groups` | array of enum | no | — | Items from the 3 [category groups](#category-groups). The server adds every category of a named group. | -| `limit` | number | no | `50` | 1 to 100. | -| `merchants` | array of string | no | — | Company names as `budget.getMerchants` returns them, each at least 1 character. | -| `search` | string | no | — | A case-insensitive substring of the description or the counterparty name. | -| `sort` | enum | no | `date` | `date` (newest first) or `amount` (largest first: descending for `incoming`, most negative first otherwise). | -| `to` | date | yes | — | No check against `from`. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `nextCursor` | string or `null` | Pass it as `cursor` for the next page. `null` on the last page. | -| `totals` | `{ incoming, outgoing }` | Over the whole filter, and not over the page. | -| `transactions` | array | The fields below. | +- `limit`: send a whole number. The schema also accepts a fraction. +- `amountMin` and `amountMax` compare the absolute amount. Setting either excludes zero-amount transactions. +- `groups`: each group adds every category in it to `categories`. +- `merchants` takes names as `getMerchants` returns them, matched without case. +- `search` matches the description or the counterparty name as a plain substring, without case. Unlike `getMerchants`, `%` and `_` are literal. +- `sort: "amount"` puts the largest first: descending for `direction: "incoming"`, most negative first otherwise. -| Field of one transaction | Type | Note | -| --- | --- | --- | -| `amount` | number | Minor units. Negative for money out. | -| `category` | string | The effective category, so an override of the user wins. | -| `counterpartyName` | string or `null` | The counterparty name the bank sends. | -| `currency` | string | The ISO 4217 code of the transaction. | -| `date` | string | ISO 8601. | -| `derivedCategory` | string | What the pipeline resolved, before any override. | -| `description` | string | The narrative of the bank. | -| `id` | string | The transaction id. | +**Output**: `{ nextCursor, totals, transactions }`. `nextCursor` is `null` on the last page. `totals` is `{ incoming, outgoing }` over the whole filter, not the page, both positive. -**Errors**: none. +| Field of one transaction | Note | +| --- | --- | +| `amount` | Negative for money out | +| `category` | The effective category: the user's override wins | +| `derivedCategory` | What the pipeline resolved, before any override | +| `counterpartyName` | As the bank sends it, or `null` | +| `currency`, `date`, `description`, `id` | As stored | ### budget.recategorise -Protected. Drops the pipeline verdicts and runs the pipeline again. It claims the sync run of the caller first, so it refuses while a sync runs. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| ------------- | ------- | ------------------------------------------------ | -| `categorised` | number | Transactions the run resolved. `0` when refused. | -| `started` | boolean | `false` when another run already holds the user. | +Drops the pipeline's verdicts and runs it again over every transaction. It claims the caller's sync run first, so it refuses while a sync runs, and `getSyncStatus` shows it in the `categorising` phase. -**Errors**: none. +**Output**: `{ categorised, started }`. `started: false` and `categorised: 0` mean another run already holds the user. ### budget.syncAccounts -Protected. Pulls fresh data from the bank provider, then categorises what is new. +**Input**: send `{}` to sync every `ACTIVE` connection of the caller. `connectionId` narrows it to one; an id that isn't the caller's or isn't `ACTIVE` syncs nothing and still answers `success: true`. `force: true` reads the whole 90-day provider window again. -**Input**: the whole object is optional. Send `{}` to sync every active connection. - -| Field | Type | Required | Default | Bounds | -| --- | --- | --- | --- | --- | -| `connectionId` | string | no | Every active connection | Narrows the sync to one bank connection. | -| `force` | boolean | no | `false` | `true` re-reads the whole provider window. | - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `categorised` | number | Transactions the run resolved. | -| `error` | string or `undefined` | Provider failures, joined together. | -| `started` | boolean | `false` when another run already holds the user. Nothing ran. | -| `success` | boolean | `false` when at least one connection failed. | -| `warning` | string or `undefined` | A categorisation failure. | - -**Errors**: none. +**Output**: `{ categorised, error, started, success, warning }`. `success` is `false` when at least one connection failed, and `error` joins the provider failures. `warning` carries a categorisation failure. `started: false` means another run holds the user and nothing ran. -It returns HTTP 200 even when every connection failed: read `success`, not the status. One sync runs per user; a second call answers `started: false` and does nothing, so follow `budget.getSyncStatus` instead. A run silent for five minutes counts as abandoned. +It answers HTTP 200 even when every connection failed: read `success`, not the status. One sync runs per user, so a second call answers `started: false`; follow `budget.getSyncStatus` instead. ### budget.updateTransactionCategory -Protected. Sets the category of one transaction, and remembers the merchant. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `category` | enum or `null` | yes | One of the 27 [spending categories](#spending-categories). `null` clears the override. | -| `transactionId` | string | yes | Must belong to the caller. | - -**Output** +**Input**: `transactionId` must belong to the caller. `category: null` clears the override. -| Field | Type | Note | -| --- | --- | --- | -| `additionalUpdated` | number | Sibling transactions of the same merchant the write also moved. | -| `category` | string | The effective category after the write. | -| `derivedCategory` | string | What the pipeline resolves without the override. | +**Output**: `{ additionalUpdated, category, derivedCategory }`: how many other transactions of the merchant moved, the effective category, and what the pipeline resolves without the override. -**Errors** +The category is remembered for the merchant and copied to its other transactions that have no override of their own; `null` forgets it and clears theirs. A transaction with no merchant key changes alone and `additionalUpdated` is `0`. -- `NOT_FOUND` (404) `Transaction not found`. +**Errors**: `NOT_FOUND` (404) `Transaction not found`. ## `instance` -The setup screen's procedures. Integration ids are `banking` (variants `disabled`, `powens`, `enable-banking`) and `email` (variants `disabled`, `resend`, `smtp`). +The setup screen's procedures. Integrations are `banking` (variants `disabled`, `powens`, `enable-banking`) and `email` (variants `disabled`, `resend`, `smtp`). ### instance.status -Public. Reports the setup state. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `claimed` | boolean | `true` once an operator exists. | -| `completed` | boolean | `true` once the operator pressed `Finish setup`. | -| `isOperator` | boolean | `true` when the caller is the operator. `false` with no session. | - -**Errors**: none. +**Output**: `{ claimed, completed, isOperator }`. `claimed` is `true` once an operator exists, `completed` once the operator pressed `Finish setup`. ### instance.describe -Operator. Describes each integration and where its values come from. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `fault` | string or `null` | Why the stored values were rejected at startup, `null` when they weren't. | -| `integrations` | array | One entry per integration, below. | -| `restartRequired` | boolean | `true` when a saved value isn't in use yet. | - -| Field of one integration | Type | Note | -| --- | --- | --- | -| `configuredBy` | `"environment"`, `"instance"` or `null` | Who configures the selected variant. | -| `discriminantKey` | string | The variable that picks the variant, such as `BANKING_PROVIDER`. | -| `discriminantSource` | `"environment"`, `"instance"` or `"unset"` | Where that variable comes from. | -| `id` | string | `banking` or `email`. | -| `selectedVariantId` | string | The variant in use. | -| `state` | `"disabled"`, `"incomplete"` or `"configured"` | | -| `variants` | array of `{ id, fields }` | Each field is `{ key, kind, present, required, source, value }`. `value` is `null` for a secret, and masked when the environment owns it. | +**Output**: `{ fault, integrations, restartRequired }`. `fault` says why the stored values were rejected at startup, or is `null`. `restartRequired` is `true` when a saved value isn't in use yet. -**Errors**: none. +| Field of one integration | Note | +| --- | --- | +| `id`, `selectedVariantId` | The integration and the variant in use | +| `state` | `disabled`, `incomplete` or `configured` | +| `configuredBy` | `environment`, `instance` or `null` | +| `discriminantKey`, `discriminantSource` | The variable that picks the variant, such as `BANKING_PROVIDER`, and whether it comes from `environment`, `instance` or is `unset` | +| `variants` | `{ id, fields }`. A field is `{ key, kind, present, required, source, value }`; `value` is `null` for a secret and masked when the environment owns a text or URL value | ### instance.check -Operator. Tests credentials against the provider without saving them. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `integrationId` | string | yes | `banking` or `email`. | -| `values` | object of string to string | yes | Variable name to value. Environment-owned values are filled in by the server. | -| `variantId` | string | yes | A variant of that integration. | +**Input**: `integrationId` and `variantId` name one integration and variant. `values` maps a variable name to its value; the server fills in what the environment owns. -**Output**: one object, by `outcome`. +**Output**, by `outcome`: | `outcome` | Other fields | Meaning | | --- | --- | --- | -| `verified` | — | The provider accepted the credentials. | -| `probe-failed` | `detail`, `reason`, `status` | The provider refused or couldn't be reached. `status` is the HTTP status of a refusal, else `null`. | -| `missing-fields` | `keys` | Required fields are empty. | -| `invalid` | `detail` | A value fails validation. | -| `environment-owned` | `keys` | The environment picks another variant. `keys` names the variable, such as `BANKING_PROVIDER`. | - -**Errors** +| `verified` | — | The provider accepted the credentials. A `disabled` variant, with nothing to check, also answers `verified` | +| `probe-failed` | `detail`, `reason`, `status` | The provider refused, timed out after 10 s, or couldn't be reached. `status` is the HTTP status of a refusal, else `null` | +| `missing-fields` | `keys` | Required fields are empty | +| `invalid` | `detail` | A value fails validation | +| `environment-owned` | `keys` | The environment picks another variant; `keys` names the variable | -- `NOT_FOUND` (404) `Unknown integration` or `Unknown variant`. -- `NOT_IMPLEMENTED` (501) `No check exists`. +**Errors**: `NOT_FOUND` (404) `Unknown integration` or `Unknown variant`. ### instance.save -Operator. Stores the values the environment doesn't set. Same input as `instance.check`. - -**Output**: `{ outcome: "saved", restartRequired: true }`, `{ outcome: "nothing-to-save" }` when the environment owns the whole variant, or one of the `missing-fields`, `invalid` and `environment-owned` refusals above. +Same input as `instance.check`. It stores only the values the environment doesn't set, and clears the stored values of the integration's other variants. Saving `disabled` answers `saved` and clears every stored value of that integration. -Saving one variant clears the stored values of the integration's other variants. +**Output**: `{ outcome: "saved", restartRequired: true }`, with `restartRequired` always `true`; `{ outcome: "nothing-to-save" }` when the environment owns the whole variant; or the `missing-fields`, `invalid` or `environment-owned` refusal of `instance.check`. Nothing restarts until `instance.complete`, which decides whether a restart is needed. To skip setup, save `disabled` for each integration, then call `instance.complete`. -**Errors** - -- `NOT_FOUND` (404) `Unknown integration` or `Unknown variant`. +**Errors**: `NOT_FOUND` (404) `Unknown integration` or `Unknown variant`. ### instance.complete -Operator. Marks setup as finished. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `completed` | `true` | | -| `restarting` | boolean | `true` when a saved value isn't in use yet. The server then exits 250 ms later, so its restart policy starts it with the new values. | - -**Errors**: none. +**Output**: `{ completed: true, restarting }`. `restarting` is `true` when a saved value isn't in use yet; the server then exits 250 ms later, and its restart policy starts it with the new values. ## `onboarding` ### onboarding.completeOnboarding -Protected. Stores the tax jurisdictions of the caller and marks the first-run flow as done. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `taxCountries` | array of string | yes | A minimum of one ISO 3166-1 alpha-2 code, uppercase. A maximum of 10 entries. | - -**Output** - -| Field | Type | Note | -| --------- | ------- | -------------- | -| `success` | boolean | Always `true`. | - -**Errors**: none. +**Output**: `{ success: true }`. ### onboarding.getStatus -Protected. Reports whether the caller finished the first-run flow. - -**Input**: none. - -**Output** - -| Field | Type | Note | -| --- | --- | --- | -| `completed` | boolean | `true` once the account holds a completion date. | -| `taxCountries` | array of string | The jurisdictions the caller picked, empty before that. | - -**Errors**: none. +**Output**: `{ completed, taxCountries }`. `taxCountries` is empty until the caller picks countries. ## `settings` -### settings.createCustomCategory - -Protected. Creates one custom category for the caller. - -**Input** +A category key is a spending category slug, or `custom:` for a custom category. The spec lists the slugs as the enum of `budget.getTransactions` `categories`. -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `color` | enum | yes | One of the 7 [category colours](#category-colours). | -| `icon` | enum | yes | One of the 28 [category icons](#category-icons). | -| `label` | string | yes | Trimmed, 1 to 40 characters. | -| `parentKey` | string or `null` | yes | A [category group](#category-groups) slug, or `custom:` of a top-level category the caller owns. `null` makes a top-level category. | +### settings.createCustomCategory -**Output** +**Input**: `parentKey` is a category group slug, the `custom:` of a top-level category the caller owns, or `null` for a top-level category. -| Field | Type | Note | -| ----- | ------ | --------------------------------------------------------- | -| `key` | string | `custom:` plus the new row id. Use it as a `categoryKey`. | +**Output**: `{ key }`, the `custom:` of the new category. **Errors** - `BAD_REQUEST` (400) `Unknown parent category` or `A subcategory cannot hold subcategories`. - `NOT_FOUND` (404) `Parent category not found`. -- `CONFLICT` (409) `A category with that name already exists`. The comparison ignores case. +- `CONFLICT` (409) `A category with that name already exists`, compared without case. ### settings.deleteCustomCategory -Protected. Deletes one custom category with its subcategories, and rehomes every budget line they hold. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `id` | string | yes | The row id, and not the `custom:` key. Must belong to the caller. | - -**Output** +**Input**: `id` is the row id, not the `custom:` key, and must belong to the caller. -| Field | Type | Note | -| --- | --- | --- | -| `fallbackSlug` | string | The slug the budget lines moved to. | -| `reassignedLines` | number | Budget lines the delete moved, subcategories included. | -| `deletedSubcategories` | number | Subcategories the cascade removed. | +Deletes the category and its subcategories, and moves their budget lines to one fallback slug. Under a group, that's the group's catch-all: `other-income`, `savings` or `uncategorised`. A top-level category, or a subcategory under a custom parent, falls back to `uncategorised`, not to the parent. -**Errors** +**Output**: `{ deletedSubcategories, fallbackSlug, reassignedLines }`. -- `NOT_FOUND` (404) `Category not found`. +**Errors**: `NOT_FOUND` (404) `Category not found`. ### settings.getBudgetProfile -Protected. Returns the budget lines of the caller in their saved order. - -**Input**: none. - -**Output**: `{ lines: [...] }`. - -| Field of one line | Type | Note | -| --- | --- | --- | -| `amount` | number | Minor units per month. | -| `categoryKey` | string | A spending category slug, or `custom:`. | -| `id` | string | The row id of the budget line. | -| `label` | string or `null` | `null` when the line holds no name of its own. | - -**Errors**: none. +**Output**: `{ lines }` in saved order, each `{ amount, categoryKey, id, label }`. `amount` is minor units per month. `label` is `null` when the line has no name. ### settings.listCategories -Protected. Returns the whole category tree the caller can pick from. - -**Input**: none. +**Output**: `{ categories }`, a flat array in display order: each of the 3 groups, its built-in categories, then the caller's custom categories under it; then each top-level custom category in stored order, followed by its subcategories. -**Output**: `{ categories: [...] }`. - -The array is flat, and its order is the display order. Each of the 3 groups comes first. Inside one group come its built-in categories, then the custom categories of the caller. Each top-level custom category follows, in its stored order, with its own subcategories after it. - -| Field of one entry | Type | Note | -| --- | --- | --- | -| `color` | string | One of the 7 [category colours](#category-colours). What the entry shows. For a category under a parent it is the colour of that parent, and not the value stored on the row. | -| `icon` | string | One of the 28 [category icons](#category-icons). | -| `isAssignable` | boolean | `false` for a built-in group header. | -| `isCustom` | boolean | `true` for a category of the caller. | -| `isGroup` | boolean | `true` for a group header, and for a top-level custom category. A subcategory is `false`. | -| `key` | string | A slug, or `custom:`. | -| `label` | string | An English display name. | -| `parentKey` | string or `null` | A [category group](#category-groups) slug, `custom:` for a subcategory, or `null` for a top-level row. | -| `pickedColor` | string | Present on a custom category only. The colour stored on the row, which `color` hides while the category sits under a parent. Send this back as `color` on `settings.updateCustomCategory` to keep the choice. | -| `usageCount` | number | Budget lines on a custom category. Always `0` for a built-in one. | - -**Errors**: none. +| Field | Note | +| --- | --- | +| `key`, `parentKey` | The entry's key, and its group slug, `custom:` parent, or `null` at top level | +| `label` | An English display name | +| `icon` | An icon name | +| `color` | What the entry shows. Under a parent, the parent's colour | +| `pickedColor` | Custom categories only: the stored colour. Send it back as `color` to `updateCustomCategory` to keep it | +| `isGroup` | `true` for a group header or a top-level custom category | +| `isAssignable` | `false` for a built-in group header | +| `isCustom` | `true` for the caller's own category | +| `usageCount` | Budget lines on a custom category. `0` for a built-in one | ### settings.moveCustomCategory -Protected. Swaps one custom category with its neighbour inside the same parent. A group parent and a custom parent both scope the order. - -**Input** - -| Field | Type | Required | Bounds | -| ----------- | ------ | -------- | -------------------------- | -| `direction` | enum | yes | `up` or `down`. | -| `id` | string | yes | Must belong to the caller. | - -**Output** - -| Field | Type | Note | -| ------- | ------- | ---------------------------------------------- | -| `moved` | boolean | `false` when the row is already first or last. | +**Input**: `id` must belong to the caller. The category swaps with its neighbour under the same parent. -**Errors** +**Output**: `{ moved }`, `false` when the row is already first or last. -- `NOT_FOUND` (404) `Category not found`. +**Errors**: `NOT_FOUND` (404) `Category not found`. ### settings.saveBudgetProfile -Protected. Replaces every budget line of the caller in one write. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `lines` | array | yes | 200 items at most. The array position becomes the sort order. | - -| Field of one line | Type | Required | Bounds | -| --- | --- | --- | --- | -| `amount` | integer | yes | 0 to 2147483647 minor units. | -| `categoryKey` | string | yes | A spending category slug, or a `custom:` the caller owns. A group slug fails. | -| `label` | string | yes | Trimmed, 60 characters at most. Send `""` for no name. | - -**Output** - -| Field | Type | Note | -| ----------- | ------ | ----------------------- | -| `lineCount` | number | Lines the write stored. | +**Input**: each `categoryKey` is a spending category slug or a `custom:` the caller owns; a group slug fails. Array order becomes sort order. `label` has no default: send `""` for a line with no name. -**Errors** +**Output**: `{ lineCount }`. -- `BAD_REQUEST` (400) `Unknown category: `. +**Errors**: `BAD_REQUEST` (400) `Unknown category: `. -The write replaces every line, so lines missing from the array are deleted. `label` has no default: leaving it out fails validation. +The write replaces every line: a line missing from the array is deleted. ### settings.updateCustomCategory -Protected. Rewrites one custom category. - -**Input** - -| Field | Type | Required | Bounds | -| --- | --- | --- | --- | -| `color` | enum | yes | One of the 7 [category colours](#category-colours). | -| `icon` | enum | yes | One of the 28 [category icons](#category-icons). | -| `id` | string | yes | Must belong to the caller. | -| `label` | string | yes | Trimmed, 1 to 40 characters. | -| `parentKey` | string or `null` | yes | A [category group](#category-groups) slug, `custom:` of a top-level category the caller owns, or `null`. | - -**Output** +**Input**: `id` must belong to the caller. `parentKey` follows the rules of `createCustomCategory`. -| Field | Type | Note | -| --------- | ------- | -------------- | -| `success` | boolean | Always `true`. | +**Output**: `{ success: true }`. **Errors** - `BAD_REQUEST` (400) `Unknown parent category`, `A subcategory cannot hold subcategories`, `A category with subcategories cannot be nested`, or `A category cannot be its own parent`. -- `NOT_FOUND` (404) `Category not found`, or `Parent category not found`. +- `NOT_FOUND` (404) `Category not found` or `Parent category not found`. - `CONFLICT` (409) `A category with that name already exists`. -## Enum values - -The literal values the input schemas accept. [Budget](/docs/use/budget) explains what they mean to a user. - -### Category groups - -3 values, in flow order: - -```json -["income", "investments", "spending"] -``` - -### Spending categories - -27 values, grouped by the comments. Each category also has a direction (money in, out or both), listed in [`docs/engineering/categorisation.md`](https://github.com/suiramdev/freenary/blob/main/docs/engineering/categorisation.md#the-taxonomy). - -```jsonc -[ - // income - "salary", - "self-employment", - "investment-income", - "rental-income", - "benefits", - "refunds", - "other-income", - // investments - "savings", - "securities", - "retirement", - "crypto", - // spending - "groceries", - "restaurants", - "rent-mortgage", - "bills-utilities", - "car-fuel", - "transport-travel", - "shopping", - "entertainment", - "subscriptions", - "health", - "family-education", - "taxes", - "loans-bank-fees", - "people", - "cash-withdrawal", - "uncategorised", -] -``` - -### Category colours - -7 values: - -```json -["blue", "green", "grey", "orange", "pink", "purple", "red"] -``` - -### Category icons - -28 values: - -```json -[ - "AirplaneIcon", - "ArrowsLeftRightIcon", - "BankIcon", - "BitcoinIcon", - "BriefcaseIcon", - "CarIcon", - "CashIcon", - "ChartPieIcon", - "CoinsIcon", - "DotsThreeIcon", - "FilmSlateIcon", - "FirstAidIcon", - "ForkKnifeIcon", - "GraduationCapIcon", - "HouseIcon", - "KeyIcon", - "LightningIcon", - "PiggyBankIcon", - "PlantIcon", - "ReceiptIcon", - "RefundIcon", - "RepeatIcon", - "ShieldCheckIcon", - "ShoppingBagIcon", - "ShoppingCartIcon", - "StorefrontIcon", - "TrendUpIcon", - "UsersIcon" -] -``` - - -Two hostnames, no `AUTH_COOKIE_DOMAIN`, and an `http://` `BETTER_AUTH_URL` stop the `server` container from starting. Use HTTPS, one hostname, or the shared parent domain. +The `server` container refuses to start when: - +- `AUTH_COOKIE_DOMAIN` ends with a dot, has a single label, or isn't a parent of both hostnames; +- the hostnames differ, `AUTH_COOKIE_DOMAIN` is unset, and `BETTER_AUTH_URL` uses `http://`. -Check it with `curl https://api.example.com/`, which answers `OK`. +A public suffix such as `.co.uk` isn't detected: name a domain you own. ## Trusted proxies -Sign-in rate limits are counted per caller address, read from `X-Forwarded-For`. The web app passes that header to the API server unchanged. +Rate limits count per caller address, read from `X-Forwarded-For`. -- **One address in the header**: Freenary trusts it. A single proxy that replaces the header (Caddy's default, nginx with `$remote_addr`, Traefik in a panel) needs no setting. -- **Several addresses**: Freenary can't tell which one is real unless you list the proxies. With no list, all visitors share one rate-limit bucket, and one visitor can use up everyone's sign-in attempts. +- **One address in the header:** Freenary uses it. A single proxy that replaces the header, such as nginx with `$remote_addr`, needs no setting. +- **Several addresses:** Freenary ignores the header unless you list your proxies. Every such caller then shares one bucket, so one visitor can use up everyone's sign-in attempts. -List every proxy address that appears in the chain, as single addresses or CIDR ranges, IPv4 or IPv6, separated by commas: +List each proxy as an address or CIDR range, comma-separated: ```dotenv TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 ``` -Freenary reads the chain from the right and takes the first address that isn't listed. Write IPv4 addresses in IPv4 form: `::ffff:10.0.0.1` stops the start. +Freenary reads the chain from the right and takes the first address that isn't listed. Write IPv4 in IPv4 form: `::ffff:10.0.0.1` stops the start. Behind a CDN, list its published ranges, and make your proxy keep the CDN's `X-Forwarded-For` instead of replacing it. -## Behind a CDN +## Check it -A CDN such as Cloudflare adds one more hop. Two settings keep the real address: +```bash +curl -L -o /dev/null -w '%{http_code} %{url_effective}\n' https://freenary.example.com/ +curl https://freenary.example.com/api/auth/ok +``` -1. Tell your proxy to trust the CDN's address ranges, so it keeps the CDN's `X-Forwarded-For` instead of replacing it with the CDN address. On Dokploy and Coolify, add the ranges to `forwardedHeaders.trustedIPs` of Traefik's HTTPS entry point. In Caddy, use the `trusted_proxies` option. In nginx, switch to `$proxy_add_x_forwarded_for`. -2. Set `TRUSTED_PROXIES` to the same CDN ranges. +The first prints `200 https://freenary.example.com/login`, the second `{"ok":true}`. With two subdomains, `curl https://api.example.com/` prints `OK`. -Get the ranges from your CDN's documentation; they change over time. +A `502` with the body `Bad Gateway` means the web app didn't reach the API server: read `docker compose logs server`. @@ -143,10 +106,4 @@ Get the ranges from your CDN's documentation; they change over time. description="Every variable, its default and what stops a start." /> - - diff --git a/apps/fumadocs/content/docs/next/reference/scaling.mdx b/apps/fumadocs/content/docs/next/reference/scaling.mdx index 97cff8a..e5f6a1e 100644 --- a/apps/fumadocs/content/docs/next/reference/scaling.mdx +++ b/apps/fumadocs/content/docs/next/reference/scaling.mdx @@ -1,94 +1,56 @@ --- title: Scaling -description: What holds state, what a second API replica shares, the limits the Compose files set, and what to measure as an instance grows. -icon: Gauge +description: Shared state, sync ownership, database connections, storage growth, and replication boundaries. +icon: Scaling --- -One instance goes a long way: all state is in PostgreSQL, and the API server keeps nothing on disk. This page is for planning a bigger instance or a second API replica. +Use these limits to size one instance or add web and API replicas. PostgreSQL is the only durable runtime dependency and the first resource to watch. -## What holds state +## Shared state -| Container | State | -| --- | --- | -| `postgres` | Everything, in one named volume mounted at `/var/lib/postgresql` | -| `server` | Nothing on disk. In memory: the merchant dictionary, loaded from the image, and the setup values, read once at startup | -| `web` | Nothing | -| `migrate` | Nothing. It applies migrations and exits | +The web service is stateless. API replicas share users, sessions, passkey challenges, rate-limit counters, bank data, assistant conversations and setup state through PostgreSQL. Sticky sessions aren't required. -The volume key is `freenary_postgres_data` in both Compose files. The root file sets the project name `freenary`, so Docker names the volume `freenary_freenary_postgres_data`. Coolify uses `freenary-postgres-data` with its own prefix. This is the one volume to size and back up. +Instance settings are read once when each API process starts. Restart every replica after changing the environment or setup-screen values. `Finish setup` exits only the API process that handled the request. -## A second API replica +Production writes no application data to container disks. It schedules no background jobs, sync timers or cleanup timers. The only timer waits 250 milliseconds before that setup exit; a supervisor must start the process again. -Nothing in the code prevents one. No Compose file sets up replicas or a load balancer, so the setup is yours. What they share: +Outside production, the event logger writes local files. Container engines and hosting panels also keep their own logs, so configure log rotation separately. -- **Sessions** are read from the `session` table on every request. There's no cookie cache and no external store. -- **Rate limits** are counted in the `rate_limit` table, so replicas count into the same rows. -- **No background jobs.** There's no queue, scheduler, cron or Redis. A bank sync runs inside the request that started it, so two replicas can't run the same job twice. -- **No local files.** In production the server writes nothing to disk. +## One sync per user -Each replica loads its own copy of the merchant dictionary from the same image, so they can't disagree. +A sync runs inside the API request that started it. Freenary claims one `sync_run` row for the user, so another tab or replica reads progress instead of starting duplicate work. - +A process refreshes progress at most once a second. Another request can take over after five minutes without a write. The claim's start time prevents a displaced process from overwriting the new run's progress. -Each replica reads the setup values once, at startup. `Finish setup` restarts only the replica that answered it. Restart every other replica afterwards, or they keep the old values. If the setup screen shows `The server did not restart`, restart all replicas and press `Try again`. +Users can sync concurrently with each other. Within one user's sync, bank connections, accounts and transaction writes run in sequence. Freenary has no worker queue or scheduled import. - +## Connections per replica -## Database connections +Each API process creates two Prisma clients: one for the application and one for Better Auth. Each uses `PrismaPg` without a configured pool size, so the `pg` default applies. -Each API replica opens two connection pools (two Prisma clients), each at the `pg` library's default size. PostgreSQL runs with the default `max_connections` of the `postgres:18` image. Each pool holds up to 10 connections, so plan on about 20 per replica against PostgreSQL's default limit of 100. No variable or URL parameter changes the pool size. Watch the connection count as you add replicas. +The default is up to 10 connections per pool, or 20 per API replica. Leave room for migrations, backups and administration when setting PostgreSQL's connection limit or choosing a replica count. -## Limits in the Compose files - -| Setting | Value | -| --- | --- | -| CPU and memory limits | None | -| Log rotation | None: Docker's default driver, unbounded | -| Restart policy | `unless-stopped` on `postgres`, `server` and `web` in both Compose files. `deploy/coolify.yml` sets none and relies on Coolify | -| Health checks | `postgres`, `server` and `web`: every 10 s, 5 s timeout, 5 retries, plus a 10 s start period for the apps | -| Signals | `init: true` on `server` and `web` | - -To cap a service, add limits in your copy of the Compose file: - -```yaml -services: - server: - deploy: - resources: - limits: - cpus: "1.0" - memory: 1g -``` +Web replicas don't open database connections. The one-shot migration service opens its own client while a deployment starts. ## What grows -- **The database volume**, with every account's transaction history. It only grows. -- **Calls to the model endpoint**, one or more per assistant question, capped at 30 questions per 300 seconds per user. - -The merchant dictionary isn't state: each image update replaces it. +PostgreSQL grows with accounts, transactions, holdings, users, sessions and rate-limit keys. It also retains assistant conversations after a user starts a new one, and caches classifier results by input signature. -## What to measure +A forced sync upserts the same provider transactions instead of appending another copy. Removing a bank connection cascades through its accounts, transactions and holdings. -There's no metrics endpoint. Three numbers cover most needs: +The merchant dictionary lives inside the server image. It consumes image and per-process memory, not database or volume space. -| Number | Where | -| ---------------------- | ------------------------------------------------- | -| PostgreSQL connections | `pg_stat_activity` | -| Volume size | `docker system df -v` | -| API response codes | The `server` container logs, one line per request | +## Volumes - +Each compose file has one PostgreSQL volume and no application volume: - +| Compose file | Logical volume | +| --------------------------- | ------------------------ | +| `docker-compose.yml` | `freenary_postgres_data` | +| `deploy/docker-compose.yml` | `freenary_postgres_data` | +| `deploy/coolify.yml` | `freenary-postgres-data` | +| `docker-compose.dev.yml` | `pgdata` | - +Docker or the hosting panel prefixes the actual volume name with its project name. Back up the volume mounted at `/var/lib/postgresql`, not a guessed host name. - +Scale only after monitoring database connections, query latency, sync duration, memory and disk growth. [API](/docs/reference/api) describes the request limits; [Updates and backups](/docs/run/updates-and-backups) protects the shared state. diff --git a/apps/fumadocs/content/docs/next/reference/security.mdx b/apps/fumadocs/content/docs/next/reference/security.mdx index 45ca468..cbedae2 100644 --- a/apps/fumadocs/content/docs/next/reference/security.mdx +++ b/apps/fumadocs/content/docs/next/reference/security.mdx @@ -1,124 +1,107 @@ --- title: Security -description: What the instance exposes, the secrets it depends on, the operator account, the sign-in protections, and a checklist before real users arrive. +description: Network exposure, secret rotation, access boundaries, and every path that sends data outside your instance. icon: ShieldCheck --- -Freenary holds every user's bank data. Your part is the network edge and the secrets. Work through [the checklist](#checklist) before you invite anyone. +Use these boundaries to deploy Freenary safely. Start with HTTPS, private database access, and a secret you can restore with the database. -## Ports +## Network edge -| File | `server` (3000) | `web` (3001) | `postgres` (5432) | -| --- | --- | --- | --- | -| `deploy/docker-compose.yml`, `deploy/coolify.yml` | Not published | Not published | Not published | -| Root `docker-compose.yml` | Every interface | Every interface | `127.0.0.1` only | +The root `docker-compose.yml` publishes the API on port 3000 and the web app on 3001, on every host interface by default. PostgreSQL binds to `127.0.0.1` only. -With the root file, prefix both app ports with `127.0.0.1` (`SERVER_PORT=127.0.0.1:3000`, `WEB_PORT=127.0.0.1:3001`) and point your proxy at the loopback interface. The database port is there for host tools like `pg_dump`; delete the `ports:` block of `postgres` if you don't need it. +The panel-oriented `deploy/docker-compose.yml` publishes no host ports. Its proxy routes one hostname to the web container on port 3001. -Neither app handles TLS. Your proxy must serve HTTPS, and `BETTER_AUTH_URL` must start with `https://`, because the session cookie takes its `Secure` flag from it. See [Reverse proxy](/docs/reference/reverse-proxy). +If a proxy runs on the host with the root compose file, bind the public services to loopback: -No Dockerfile sets a `USER` and no Compose file sets `user:`, so each container runs as its base image's default user. +```dotenv +SERVER_PORT=127.0.0.1:3000 +WEB_PORT=127.0.0.1:3001 +``` -## Secrets +Don't publish PostgreSQL publicly. Docker-published ports can bypass host firewall rules, so verify the listening addresses after deployment changes. -| Secret | Rule | Protects | -| --- | --- | --- | -| `BETTER_AUTH_SECRET` | 32 characters or more | Session cookies, two-factor data, the bank connection state, and the setup values | -| `POSTGRES_PASSWORD` | No default | The database | -| Bank and email credentials | Entered on the setup screen, or in the environment | Every user's bank connections, sign-in codes and password resets | -| `AI_API_KEY` | Optional | Your account at the model endpoint | +The server and web images declare no non-root user, so their application processes run as root inside each container. Add no host mounts, Docker socket or capabilities. -Generate the two required values with `openssl rand -hex 32` and `openssl rand -hex 16`. Compose refuses to start with either one empty. Coolify and the Dokploy template generate them for you. +Session cookies are always `HttpOnly`. They get `Secure` only when `BETTER_AUTH_URL` starts with `https://`; TLS at the proxy doesn't change a value left as `http://`. - +Cookie scope, one-hostname and two-subdomain setups are in [Reverse proxy](/docs/reference/reverse-proxy). Set [trusted proxies](/docs/reference/reverse-proxy#trusted-proxies) so rate limits see each caller instead of one shared address. -Outside production mode, `BETTER_AUTH_SECRET` falls back to a public development value, and anyone can forge a session against it. The self-hosting Compose files always set `NODE_ENV=production`, which removes that fallback. If you run Freenary without Docker, set `NODE_ENV=production` too. +## Secrets and sealing - +`BETTER_AUTH_SECRET` must have at least 32 characters. It protects: -### What a rotation costs +- session and trusted-device cookie signatures; +- authenticator secrets and two-factor backup codes; +- bank-link state against tampering; +- bank and email values saved on the setup screen. -| Rotate | Cost | -| --- | --- | -| `BETTER_AUTH_SECRET` | Everyone is signed out, every two-factor enrolment stops, bank connections in progress fail, and the setup values become unreadable, so the operator enters them again | -| `POSTGRES_PASSWORD` | The database volume keeps its first password. Change it inside the database first, then update the variable and run `docker compose up -d` | -| A bank or email credential | Rotate it at the provider, then enter it on the setup screen and press `Finish setup`. If it's in the environment, update it there and run `docker compose up -d` | +Setup values use authenticated encryption with a key derived from this secret. Environment values are never sealed and win over stored values. -To change the database password inside the running container: +Passwords and emailed one-time codes are hashed. Keep `BETTER_AUTH_SECRET`, credentials, `.env` files and database dumps together: a dump alone can't recover sealed setup values. -```bash -docker compose exec postgres psql -U postgres -c "ALTER USER postgres PASSWORD ''" -``` +## What a rotation costs -## The operator account +Changing `BETTER_AUTH_SECRET` signs everyone out. Trusted-device cookies stop working, bank-link state fails verification, and stored authenticator secrets and backup codes no longer decrypt. -The first account to sign in becomes the operator. One database transaction grants the role, so two sign-ins at the same moment can't both get it. On an instance upgraded from an older release, the oldest account with a confirmed email gets the role, or the oldest account if none is confirmed. +Restore the old secret when possible. Otherwise, a user with a passkey or linked provider can sign in, disable two-factor with their password, then enrol again. -There's one operator, and no screen gives the role to anyone else. It opens the setup screen and nothing more: the operator can't see other users' financial data. +With no alternate sign-in, the operator must clear that user's two-factor flag and row in PostgreSQL. The user then signs in by password and enrols again. - +Every setup value sealed under the old secret is skipped, with one warning per value in the server log. Environment values keep working. [Troubleshooting](/docs/run/troubleshooting#bank-and-email-stopped-working-after-a-restore) shows the warning and how to recover. -A new instance on a public address is open to whoever finds it first. Create your account right after the first start. +## Operator boundary - +The first account to create a session wins the operator claim. No later sign-in changes it. -## Sign-in protections +The operator can inspect, test, save and finish instance setup. The role grants no access to another user's finances or conversations. Freenary has no in-app role transfer. -On by default, no setting needed: +If the wrong account won, follow [The wrong account became the operator](/docs/run/troubleshooting#the-wrong-account-became-the-operator). Treat an unknown operator as a compromised installation. -- Passwords are 12 to 128 characters, hashed with scrypt. -- New passwords are checked against Have I Been Pwned. This fails closed: without a route to `api.pwnedpasswords.com`, sign-up and password reset fail. Set `AUTH_PASSWORD_BREACH_CHECK=false` on an offline server. -- Ten wrong second-factor codes in a row lock the account for 15 minutes. -- Sensitive account changes, such as removing a passkey, need a session under 15 minutes old. -- Rate-limit counters live in the database, so a restart doesn't clear a lockout. -- Passkeys work, bound to `CORS_ORIGIN`. +## Sign-in protections -Turned on by your configuration: +Freenary supports password checks, database-backed rate limits, two-factor authentication, trusted devices, passkeys and recent-session checks. -| Protection | Needs | -| --- | --- | -| Email confirmation and password reset | An email provider. See [Email](/docs/run/email) | -| Google, Apple or single sign-on | Both variables of that provider. See [Sign-in](/docs/run/sign-in) | -| One rate-limit bucket per visitor | A single proxy, or `TRUSTED_PROXIES` for a chain. See [Trusted proxies](/docs/reference/reverse-proxy#trusted-proxies) | +The [leaked-password check](/docs/run/sign-in#leaked-password-check) contacts Have I Been Pwned and fails closed. -## Data +[Sign-in](/docs/run/sign-in) gives the password limits, session lifetime, lockout figures, verification-code limits and provider behaviour. -The database holds every user's accounts, transactions and bank provider tokens. The tokens are stored as the provider sent them, so anyone who can read the database can call the provider as that user. The setup values are encrypted with AES-256-GCM under a key derived from `BETTER_AUTH_SECRET`. +## Data - +Accounts, transactions, plans, conversations and credentials live in PostgreSQL. Production writes no application data to the container filesystem. -Treat the database and every dump as the financial data itself. Encrypt dumps, limit who can read them, and keep them apart from the server. +Data leaves the instance through these paths: - +- **Bank providers** receive connection and sync requests. A database reader gets a usable Powens user token. Enable Banking stores no per-user token; requests need the application's private key. +- **The assistant endpoint** receives the question, conversation context and requested finance data. This can include account and bank names, transactions, counterparties, categories and aggregates, but not IBANs or balances. +- **Classifier endpoints** receive merchant descriptors, counterparties, country, currency, direction, channel, merchant category, a coarse amount bucket and routing fields. They don't receive exact amounts, dates, IBANs or accounts. +- **Email and sign-in providers** receive the addresses, codes and provider requests their feature needs. +- **The breach check** receives the five-character hash prefix described above. -- **Account deletion.** No screen or endpoint deletes a user. To erase one person, the operator does it in the database. Disconnecting a bank deletes its accounts, transactions and holdings. -- **The assistant.** With a model endpoint configured, each question sends that user's financial data to it. See [Assistant](/docs/use/assistant). -- **The API reference.** `/api-reference` is a live API: anyone with a session can call every procedure there. Block the prefix at your proxy if you don't want it public. See [API](/docs/reference/api). +A device model downloads into the user's browser. Finance data stays there, while the conversation is saved on the instance. -## Checklist +The server-image build downloads its merchant dictionary from this repository's GitHub releases. Its fallback contacts GeoNames, Wikidata and npm. It sends no user data, and the running image reads the file locally. -- [ ] `BETTER_AUTH_SECRET` and `POSTGRES_PASSWORD` are your own values. -- [ ] The public URL is `https://` and matches the browser address exactly. -- [ ] No app port faces the internet, and `postgres` is published on `127.0.0.1` at most. -- [ ] `TRUSTED_PROXIES` lists every proxy in a multi-hop chain. -- [ ] Two subdomains only: `AUTH_COOKIE_DOMAIN` names the shared parent domain. -- [ ] Your own account is the operator. -- [ ] Email is set up, so password reset works. -- [ ] You know which endpoint `AI_BASE_URL` names, or it's unset. -- [ ] Backups run on a schedule, and you've restored one. See [Updates and backups](/docs/run/updates-and-backups). +`/api-reference` is a live OpenAPI endpoint. It and `/rpc` reach the same handlers with the same session and role checks. See [API](/docs/reference/api) before automating either path. + + diff --git a/apps/fumadocs/content/docs/next/run/email.mdx b/apps/fumadocs/content/docs/next/run/email.mdx index ae0e2f5..e38e8d4 100644 --- a/apps/fumadocs/content/docs/next/run/email.mdx +++ b/apps/fumadocs/content/docs/next/run/email.mdx @@ -1,30 +1,30 @@ --- title: Email -description: Let Freenary send confirmation and password-reset codes, through Resend or any SMTP server, set in the setup screen. +description: Let Freenary send confirmation and password-reset codes, through Resend or any SMTP server. icon: Mail --- -Freenary sends email for two things: confirming a new account's address and resetting a forgotten password. Both use a one-time code. Set up email before you invite anyone. +Send account confirmation and password-reset codes through Resend or an SMTP service. -## Why set it up +## What changes when email is enabled -A fresh instance sends no email. Sign-in still works with a password or a passkey, but: +Without a delivery service, Freenary allows password and passkey sign-in but doesn't confirm addresses. It also hides `Forgot password?`. -- New accounts aren't asked to confirm their address. -- There's no `Forgot password?` link. A user who forgets their password can't get back in on their own. +After you configure one: -Once email works, new accounts confirm their address with a 6-digit code, and anyone can reset a password from the sign-in screen. Accounts created before that, yours included, confirm their address with a code at their next password sign-in. +- A new user must confirm their address before signing in. +- An existing user with an unconfirmed address receives a code on their next password sign-in. +- `Forgot password?` sends a reset code. -## Choose a service +The figures for code length, lifetime and attempts are in [Sign-in](/docs/run/sign-in). Messages contain plain English text. -| Service | Good for | You need | -| --- | --- | --- | -| [Resend](#resend) | The quickest start | A Resend account and a domain you can add DNS records to | -| [SMTP](#smtp) | A mail provider or relay you already use | The server address, port and login | +## Choose where the values live -Both are set in the [setup screen](/docs/get-started/connect-a-bank), at `https://freenary.example.com/setup`, in the `Email` step. As the operator, you can open `/setup` again at any time to change them. +The operator can open `https://freenary.example.com/setup` and select the `Email` step. Values saved there stay in the database. -The setup screen tests the connection before it saves, so a wrong key or password shows up right away rather than at someone's first sign-up. If the test fails but you're sure of the values, `Save anyway` keeps them. +Environment variables are the other option. Start with `EMAIL_PROVIDER` and `EMAIL_FROM`, then add the variables for your service. An environment value overrides a saved value. The screen labels it `Set by your server`. + +When the environment supplies every required field, the screen reports that service as ready and offers `Test connection`. See [A value in the environment wins](/docs/reference/configuration#a-value-in-the-environment-wins). ## Resend @@ -32,28 +32,24 @@ The setup screen tests the connection before it saves, so a wrong key or passwor -### Get an API key +### Prepare Resend -In the [Resend dashboard](https://resend.com), add and verify your domain, then create an API key with full access. The setup screen's test reads your domains, which a sending-only key can't do. +Add your domain in the [Resend dashboard](https://resend.com) and verify its DNS records. Create a full-access API key. A sending-only key can't pass Freenary's domain-list test. -### Enter it in the setup screen - -Open `/setup`, go to `Email`, choose `Resend`, and fill in: +### Enter the credentials -- `Sender address` — for example `Freenary `. Use an address on the domain you verified. -- `API key` — the key from Resend, starting with `re_`. +Choose `Resend` in the `Email` step. Enter a `Sender address` on the verified domain, such as `Freenary `, and enter the `API key`. +### Apply the Resend change -### Save and finish - -Press `Save and continue`, then `Finish setup`. Freenary restarts to apply the change. +Press `Save and continue`, followed by `Finish setup`. The instance restarts with the saved service. @@ -61,48 +57,58 @@ Press `Save and continue`, then `Finish setup`. Freenary restarts to apply the c ## SMTP -Open `/setup`, go to `Email`, choose `SMTP`, and fill in what your provider gives you. `Port`, `Implicit TLS`, `User` and `Password` are under `More options`. + -| Field | What to enter | + + +### Enter the server details + +Choose `SMTP` in the `Email` step. Enter `Sender address` and `Host`, then open `More options` for the remaining fields. + +| Field | Value | | --- | --- | -| `Sender address` | For example `Freenary ` | -| `Host` | Your provider's SMTP server, such as `smtp.example.com` | -| `Port` | Usually `587`. Leave it empty for the default | -| `Implicit TLS` | Turn it on for port `465`. Leave it off for `587`, which upgrades to TLS on its own | -| `User` and `Password` | Your SMTP login. Leave both empty for a relay that needs no login | +| `Port` | Empty selects `465` with `Implicit TLS`, or `587` without it | +| `Implicit TLS` | On for port `465`; off for `587`, which upgrades its connection | +| `User`, `Password` | Your SMTP login, or both empty for an unauthenticated relay | -Press `Save and continue`, then `Finish setup`. + -## Check that it works + -Sign up with an address that has no account yet. A code titled "Confirm your Freenary email address" should arrive within a minute. To test a reset instead, press `Forgot password?` on the sign-in screen. +### Apply the SMTP change -## If emails don't arrive +Press `Save and continue`, followed by `Finish setup`. -| Problem | Fix | -| --- | --- | -| The message lands in spam | Add SPF, DKIM and DMARC records for your sender's domain, as your provider explains | -| The provider refuses the sender | Use a sender address on a domain you verified with that provider | -| The code no longer works | A code lasts 10 minutes, and three wrong tries cancel it. Ask for a new one | -| A new code doesn't arrive | Codes are limited to 3 requests per minute. Wait, then try again | + + + + +## Connection test results -Emails are plain text and in English only. +`Save and continue` tests the service before storing the values. The test times out after 10 seconds. + +| Message | Action | +| --- | --- | +| `Connection works.` | Continue to the final step | +| `… refused these credentials. Check them and try again.` | Correct the key or login | +| `Unable to reach …. Check the address and your network, then try again.` | Check the host, port and outbound access | +| `A value has the wrong format. Check the fields and try again.` | Correct the invalid field | - +If the service is working despite the failed test, press `Save anyway`. The screen stores the values without checking them. -You can also set email with environment variables (`EMAIL_PROVIDER`, `EMAIL_FROM` and the rest). A value set there wins, and the setup screen shows it as `Set by your server`. See [Configuration](/docs/reference/configuration#a-value-in-the-environment-wins). +## Send a real code - +Create an account with an unused address. Within a minute, expect a message titled `Confirm your Freenary email address`. Check spam and your sender domain's SPF, DKIM and DMARC records when it doesn't arrive. diff --git a/apps/fumadocs/content/docs/next/run/sign-in.mdx b/apps/fumadocs/content/docs/next/run/sign-in.mdx index 823d629..7ad1735 100644 --- a/apps/fumadocs/content/docs/next/run/sign-in.mdx +++ b/apps/fumadocs/content/docs/next/run/sign-in.mdx @@ -1,64 +1,60 @@ --- title: Sign-in -description: Who can create an account on your instance, the sign-in methods it offers, and how to add Google, Apple or single sign-on. +description: The sign-in methods your instance offers, how long sessions last, and how to add Google, Apple or single sign-on. icon: KeyRound --- -This page is for operators. It explains who can join your instance and which ways in you can offer. What users see is in [Your account](/docs/use/account#sign-in). +Choose the sign-in methods available on your instance and configure external providers here. Users manage their own methods from [Your account](/docs/use/account#sign-in). -The variables on this page go in your instance's environment, then you redeploy. On Coolify, first [add each one to the compose file](/docs/reference/configuration#add-a-variable-on-coolify). +Set provider variables in the deployment environment. Recreate the containers with `docker compose up -d`, or redeploy through your hosting panel. -## Who can create an account +## Built-in methods -Anyone who can reach your instance's address can create an account. There's no setting to close sign-up or require an invitation. +Every instance supports these methods: -- **The first account to sign in becomes the operator.** Only the operator can open the setup screen. Create your account right after you install, before you share the address. -- Every account sees only its own banks, transactions and plan. The operator can't see other users' data. -- To keep strangers out, put the instance behind something that limits who can reach it, such as a VPN, a tunnel with access rules, or your proxy's authentication. +- **Password:** between 12 and 128 characters. +- **Passkey:** a device uses its fingerprint, face or PIN. +- **Two-factor authentication:** an authenticator-app code after password sign-in. - +## Sessions -If someone else became the operator on a fresh instance, treat it as compromised: remove it with its database volume and install again. See [Troubleshooting](/docs/run/troubleshooting#the-wrong-account-became-the-operator). +A session expires after 7 days. Using it after one day renews that period, so inactivity signs a user out after about 6 to 7 days. - +Don't change `BETTER_AUTH_SECRET` to shorten sessions. Rotation has wider effects, described in [Security](/docs/reference/security). -## What every instance offers +## One-time codes -These work with no configuration: +[Email delivery](/docs/run/email) adds codes for address confirmation and password resets. Each code has 6 digits, expires after 10 minutes and stops working after 3 wrong attempts. Each caller address can request at most 3 codes a minute. -| Method | Notes | -| --- | --- | -| Email and password | Passwords need 12 to 128 characters | -| Passkeys | Sign in with a device's fingerprint, face or PIN. Needs HTTPS | -| Two-factor authentication | An authenticator-app code on top of the password. Each user turns it on in Settings | +## Passkeys and your hostname -[Email delivery](/docs/run/email) adds address confirmation and password reset. Without it, a user who forgets their password can't reset it. +Freenary takes the passkey hostname from `CORS_ORIGIN`. Browsers allow passkeys on HTTPS addresses and `localhost`. -## Passkeys depend on your hostname - -A passkey is tied to the hostname of your instance: the one in `FREENARY_URL`, or in `CORS_ORIGIN` with the root `docker-compose.yml`. - -If you change the hostname, every passkey stops working and users must add a new one. Choose your final domain before people start using passkeys. +Changing that hostname makes existing passkeys unusable. Each affected user must add another one under the new hostname. ## Add Google, Apple or single sign-on -Each provider appears as a `Continue with …` button once both of its variables are set. They aren't in the setup screen. +External sign-in buttons appear when their variables are complete. These settings aren't available in the setup screen. + +| Provider | Required variables | +| -------------- | ------------------------------------------ | +| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | +| Apple | `APPLE_CLIENT_ID`, `APPLE_CLIENT_SECRET` | +| OpenID Connect | `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID` | -| Provider | Variables, both required | -| --- | --- | -| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | -| Apple | `APPLE_CLIENT_ID`, `APPLE_CLIENT_SECRET` | -| Single sign-on (OpenID Connect: Authentik, Keycloak, Zitadel, Entra…) | `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID` | +Register this callback pattern with the provider: -Setting only one half of a pair does nothing. +```text +/api/auth/callback/ +``` -At the provider, register this redirect URI, with `google`, `apple` or `oidc` at the end: +Use `google`, `apple` or `oidc` as ``. For example: ```text -https://freenary.example.com/api/auth/callback/google +https://freenary.example.com/api/auth/callback/oidc ``` -For single sign-on, a typical setup looks like this: +An OpenID Connect configuration can be: ```dotenv OIDC_DISCOVERY_URL=https://idp.example.com/.well-known/openid-configuration @@ -67,37 +63,52 @@ OIDC_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx OIDC_PROVIDER_NAME=Example SSO ``` -`OIDC_PROVIDER_NAME` sets the button text (`Continue with Example SSO`). Leave `OIDC_CLIENT_SECRET` out for a public client. Use `OIDC_SCOPES` to request extra scopes, comma-separated; `openid`, `email` and `profile` are always included. +`OIDC_CLIENT_SECRET` is optional for a public client. `OIDC_PROVIDER_NAME` changes the button label. `OIDC_SCOPES` adds comma-separated scopes to the required `openid`, `email` and `profile` scopes. + +The discovery document must provide both `issuer` and `jwks_uri`, because Freenary verifies ID tokens. When it can't build that verification, the server records: + +```text +Provider "oidc": requires verified ID tokens, but discovery did not provide a usable issuer and jwks_uri. +``` -A provider sign-in joins an existing account only when both use the same, confirmed email address. +An external provider joins an existing account only when its address matches and the account's address is confirmed. + +## Provider sign-in on an instance without email + +An instance with no email service never confirms account addresses. A provider sign-in that matches an existing password account is therefore refused with: + +```text +This address already has an account that has not been confirmed yet. Confirm it first, then the provider can be used with it. +``` + +Resolve it in either of these ways: + +- Configure [email delivery](/docs/run/email). The next password sign-in sends the user a confirmation code. They can use the provider after entering it. +- Ask the user to sign in with their password. Within 15 minutes, they open Settings, `Security`, then press `Connect` for that provider under `Connected accounts`. ## Leaked-password check -When someone picks a password, Freenary checks it against the public [Have I Been Pwned](https://haveibeenpwned.com/Passwords) list and refuses known leaked passwords. Only the first 5 characters of the password's hash leave your server. +By default, Freenary rejects a password found in the [Have I Been Pwned](https://haveibeenpwned.com/Passwords) corpus. The check runs on sign-up, change and reset. It sends only the first 5 characters of the password hash. -If your server can't reach the internet, new passwords fail to save. Turn the check off: +When `api.pwnedpasswords.com` is unreachable, [new passwords fail to save](/docs/run/troubleshooting#new-passwords-dont-save). Disable this fail-closed check only when necessary: ```dotenv AUTH_PASSWORD_BREACH_CHECK=false ``` -## Sessions and limits - -- Users stay signed in until they're inactive for 7 days on that browser. They see and sign out their sessions in Settings. -- Changing `BETTER_AUTH_SECRET` signs everyone out, breaks two-factor for every user, and makes the bank and email credentials saved on the setup screen unreadable. Keep it with your backups. -- Sign-in attempts are rate-limited per caller address. If requests pass through more than one proxy, for example a CDN in front of your proxy, list those proxies in `TRUSTED_PROXIES` so users don't share one limit. See [Reverse proxy](/docs/reference/reverse-proxy#trusted-proxies). +## Rate limits -Every variable, with its default: [Configuration](/docs/reference/configuration). +Sign-in, sign-up and code requests are limited per caller address; [API](/docs/reference/api#rate-limits) lists each limit. When more than one proxy forwards requests, add them to `TRUSTED_PROXIES`, or visitors share one caller address and one limit. See [Trusted proxies](/docs/reference/reverse-proxy#trusted-proxies). diff --git a/apps/fumadocs/content/docs/next/run/troubleshooting.mdx b/apps/fumadocs/content/docs/next/run/troubleshooting.mdx index 3b62b1c..b6d6706 100644 --- a/apps/fumadocs/content/docs/next/run/troubleshooting.mdx +++ b/apps/fumadocs/content/docs/next/run/troubleshooting.mdx @@ -1,10 +1,10 @@ --- title: Troubleshooting -description: Fix the most common problems with a Freenary instance, and read its logs. +description: Match what you see on your Freenary instance to its cause and its fix, and read the logs. icon: CircleAlert --- -Find your symptom below. Most fixes start with two commands, run from the folder that holds your `docker-compose.yml`: +Find what you see below. Most fixes start with two commands, run from the folder that holds your `docker-compose.yml`: ```bash docker compose ps -a @@ -13,9 +13,32 @@ docker compose logs --tail 100 server With a hosting panel, the same information is in the app's container list and its logs view. +## Freenary won't start + +Compose refuses before any container starts, or a container keeps restarting: `docker compose ps -a` shows `Restarting (1)`, or `docker compose up` ends with `dependency failed to start: container … is unhealthy`. Find the message in the output of `docker compose up -d` or in `docker compose logs server`. + +| What you see | Why | Fix | +| --- | --- | --- | +| `required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env`, or the same for `BETTER_AUTH_SECRET` | The value is missing or empty | Set it. Generate `BETTER_AUTH_SECRET` with `openssl rand -hex 32` and `POSTGRES_PASSWORD` with `openssl rand -hex 16` | +| `Set FREENARY_URL to the public URL of the web app, such as https://freenary.example.com` | `deploy/docker-compose.yml`, used by the Dokploy template, needs your instance's address | Set `FREENARY_URL` to it, with `https://` | +| `env file …/.env not found` | The root `docker-compose.yml` reads a `.env` file next to it, and there is none. Exporting the variables in your shell isn't enough | Create the `.env` file, as in [Install](/docs/get-started/install) | +| `Error: P1000: Authentication failed against database server`, then `service "migrate" didn't complete successfully: exit 1` | `POSTGRES_PASSWORD` changed after the first start. The database keeps the password it was created with | Put the original `POSTGRES_PASSWORD` back, then run `docker compose up -d` | +| `Please make sure your database server is running at` in `docker compose logs migrate` | `migrate` can't reach the database | Check that `postgres` shows `healthy` in `docker compose ps -a`, and read `docker compose logs postgres` | +| `❌ Invalid environment variables:`, then `error: Invalid environment variables` | A value fails its check. The `path` line names the variable, and `message` says what's wrong, such as `Too small: expected string to have >=32 characters`. A `TRUSTED_PROXIES` path with `write an IPv4-mapped address in its IPv4 form` means an entry like `::ffff:10.0.0.1`: write `10.0.0.1` | Fix that variable, then run `docker compose up -d` or redeploy. With `deploy/docker-compose.yml`, a `BETTER_AUTH_URL` or `CORS_ORIGIN` path means `FREENARY_URL` is wrong: it must be a full URL, such as `https://freenary.example.com` | +| `error: EMAIL_PROVIDER=resend requires RESEND_API_KEY to be set.`, or another `EMAIL_PROVIDER=… requires …` line | An email variable is half set in the environment | Set the variable it names, or remove `EMAIL_PROVIDER` and use the [setup screen](/docs/run/email) | +| `error: The instance configuration could not be read: …` | The server couldn't read the values saved on the setup screen, usually because the database can't be reached or isn't migrated | Check `docker compose logs migrate` and `docker compose logs postgres`, then run `docker compose up -d` | + +Every message, with the variable it comes from, is in [What stops a start](/docs/reference/configuration#what-stops-a-start). + + + +Never set `SKIP_ENV_VALIDATION` to get past a startup error. The server then runs with a broken configuration. + + + ## You can't sign in -**What you see.** Signing in fails, or you're signed in and land straight back on the sign-in screen. The passkey button may be missing. +**What you see.** After you type your email, the form doesn't go on and shows `We could not check this address`, or `Could not load the other ways to sign in`. Or you're signed in and land straight back on the sign-in screen. The setup screen may show `The setup screen did not load`. The passkey button may be missing. The server log shows nothing for the browser's own calls: the browser refuses the answer, not the server. **Why.** The address Freenary is configured with doesn't exactly match the one in your browser. Scheme, host and port must all match. @@ -27,30 +50,31 @@ FREENARY_URL=https://freenary.example.com - **Dokploy:** the template starts with `http://`. Change it to `https://` once HTTPS is on for your domain. - **Coolify:** there's no `FREENARY_URL`. Set the `web` service's domain with `https://` and redeploy. -- **Root `docker-compose.yml`:** it has no `FREENARY_URL`. Set `BETTER_AUTH_URL` and `CORS_ORIGIN` to the same address instead. If they're unset, the browser tries to reach `http://localhost:3000` and every request fails. +- **Root `docker-compose.yml`:** it has no `FREENARY_URL`. Set `BETTER_AUTH_URL` and `CORS_ORIGIN` instead: both to the same address with one hostname, or to the API's and the web app's addresses with two. If they're unset, they default to `http://localhost:3000` and `http://localhost:3001`. That works in a browser on the server itself, and fails from every other machine. Then redeploy, or run `docker compose up -d`. A `restart` isn't enough: it keeps the old values. -Changing the hostname makes existing passkeys stop working; users add new ones. +A new hostname also breaks existing passkeys: see [Passkeys and your hostname](/docs/run/sign-in#passkeys-and-your-hostname). -## The bank list is empty +## The page shows 502 Bad Gateway -**`Bank linking is unavailable`.** No bank provider is set up. As the operator, open `https://freenary.example.com/setup` and complete the `Bank` step. See [Connect a bank](/docs/get-started/connect-a-bank). +**Freenary is still starting.** On the first start, the database, `migrate`, `server` and `web` start in turn. Wait a minute and reload. -**The bank section shows `No banks available`.** Freenary reached your provider, which returned no bank. Either: +**Your proxy or panel points at the wrong place.** Your proxy shows its own 502 page and the sign-in screen never loads. With one address, your domain goes to the `web` service on port `3001`; the web app forwards the rest to the server. With two addresses, the web app's goes to `web` on `3001` and the API's to `server` on `3000`. See [Reverse proxy](/docs/reference/reverse-proxy#two-subdomains). -- your Powens domain has no active connectors: activate them in the [Powens console](https://console.powens.com), then reload; or -- no country in your tax residency is covered by the provider. +**The server is down.** The page loads, but signing in or loading data fails, and those requests answer `502` with the body `Bad Gateway`. The web app got no answer from the server within its [forwarding limit](/docs/reference/api#base-url). `docker compose logs web` shows the reason: -**`Freenary could not load banks for` a country.** The provider refused the request or couldn't be reached: check the Powens domain and credentials (or the Enable Banking ones) in the setup screen. +```text +The API server at
did not answer: +``` -**Connecting a bank ends on an error page.** The redirect URI registered at the provider doesn't match. For Powens, it must be exactly `https://freenary.example.com/callback/powens`. On `localhost`, bank linking can't work: Powens needs an HTTPS address. +Check `docker compose ps -a` and `docker compose logs server`. If the server keeps restarting, see [Freenary won't start](#freenary-wont-start). ## The server does not restart after setup -**What you see.** After `Finish setup`, the setup screen waits for a minute, then shows `The server did not restart`. +**What you see.** After `Finish setup`, the setup screen waits, then shows `The server did not restart` and `It did not answer within one minute.` -**Why.** `Finish setup` stops the server so it picks up the new settings at its next start. Something has to start it again. The compose files tell Docker to, and Coolify does it by default; a server started by hand stays down, and one that crashes on startup keeps restarting. +**Why.** `Finish setup` stops the server so it picks up the new settings at its next start. Something has to start it again. Both compose files tell Docker to, and Coolify does it by default. A server started by hand stays down, and one that fails on startup keeps restarting. **Fix.** Read the server log: @@ -58,7 +82,7 @@ Changing the hostname makes existing passkeys stop working; users add new ones. docker compose logs server ``` -Fix the error it names, then start the server: +Fix the error it names (see [Freenary won't start](#freenary-wont-start)), then start the server: ```bash docker compose up -d server @@ -66,49 +90,93 @@ docker compose up -d server With a hosting panel, read the `server` logs there and redeploy. Then press `Try again` on the setup screen. -## The page shows 502 Bad Gateway +## Bank and email stopped working after a restore -- **Freenary is still starting.** On first start, the database, `migrate`, `server` and `web` start in turn. Wait a minute and reload. -- **Your proxy or panel points at the wrong place.** Your domain must go to the `web` service on port `3001`. The web app forwards the rest to the server. -- **The server is down.** The web app answers `502` for sign-in and data when it can't reach the server. Check `docker compose ps -a` and the server log. +**What you see.** After restoring a dump or changing `BETTER_AUTH_SECRET`, everyone is signed out, and the bank and email credentials saved on the setup screen are gone. The server still starts, and its log shows one warning per saved value: -## Freenary won't start +```text +The stored value of EMAIL_PROVIDER does not open with the current BETTER_AUTH_SECRET. +``` -| What you see | Fix | -| --- | --- | -| `Set FREENARY_URL…`, `Set POSTGRES_PASSWORD…` or `Set BETTER_AUTH_SECRET…`, and nothing starts | Set the value named. For a secret, generate one with `openssl rand -hex 32` | -| The server log shows `Invalid environment variables` | The `path` line names the wrong variable. Fix it and redeploy | -| `service "migrate" didn't complete successfully` | Read `docker compose logs migrate`. A failed connection means `POSTGRES_PASSWORD` changed since the first start: put the original back | -| A line such as `EMAIL_PROVIDER=smtp requires …` | An email variable is half set in the environment. Complete it or remove it. See [Email](/docs/run/email) | +**Why.** The setup screen's values are encrypted with `BETTER_AUTH_SECRET`. The server can't read them under another secret, so it skips them and runs on the environment's values alone. + +**Fix.** Put back the secret that was in use when the dump was taken, then run `docker compose up -d`, or redeploy with a panel. If that secret is lost, open the setup screen, enter the bank and email credentials again, and press `Finish setup`. What else a new secret costs, two-factor included, is in [What a rotation costs](/docs/reference/security#what-a-rotation-costs). + +## The setup screen shows a configuration problem + +**What you see.** The setup screen shows `Configuration problem` and `The stored configuration did not load:`, followed by the reason. + +**Why.** The values saved on the setup screen, combined with your environment, don't pass the startup checks. The server ignores every saved value and runs on the environment alone. + +**Fix.** Correct the value the reason names, on the setup screen or in the environment, then press `Finish setup` or run `docker compose up -d`. + +## The bank list is empty -Never use `SKIP_ENV_VALIDATION` to get past a startup error. The server then runs with a broken configuration. +**`Bank linking is unavailable`, in Settings, `Connections`, under `Bank accounts`.** No bank provider is set up, or one of its fields is missing. Onboarding skips its bank step for the same reason. As the operator, open `https://freenary.example.com/setup` and complete the `Bank` step. See [Connect a bank](/docs/get-started/connect-a-bank). + +**`No banks available`.** Freenary reached your provider, which returned no bank. Either your Powens domain has no active connectors, which you activate in the [Powens console](https://console.powens.com), or the provider covers no country of your tax residency. + +**`Freenary could not load banks for` a country.** The provider refused the request or couldn't be reached. Check the Powens domain and credentials, or the Enable Banking ones, in the setup screen. The other countries still show. + +**Connecting a bank ends on an error page at the provider.** The redirect URI registered there doesn't match. It's your instance's address followed by `/callback/powens` or `/callback/enable-banking`. See [Bank providers](/docs/reference/bank-providers#the-redirect-uri-must-be-https). + +## New passwords don't save + +**What you see.** Signing up or resetting a password shows `Something went wrong. Try again.` The server log shows `Failed to check password. Please try again later.` or `Failed to check password. Status:` followed by a code. + +**Why.** Freenary couldn't reach Have I Been Pwned to check the password against known leaks, for example because the server has no internet access. + +**Fix.** Let the server reach `api.pwnedpasswords.com`, or turn the check off. See [Leaked-password check](/docs/run/sign-in#leaked-password-check). + +## Google, Apple or single sign-on refuses a user + +**What you see.** After the provider's screen, the sign-in screen shows `This address already has an account that has not been confirmed yet. Confirm it first, then the provider can be used with it.` + +**Why.** The user already has a password account with that address, and its address isn't confirmed. On an instance without email, no address is ever confirmed. + +**Fix.** See [Provider sign-in on an instance without email](/docs/run/sign-in#provider-sign-in-on-an-instance-without-email). + +## Everyone gets "Too many attempts" + +**What you see.** Users get `Too many attempts. Wait a moment and try again.` when signing in, after only a few tries of their own. + +**Why.** Sign-in attempts are limited per caller address. When requests pass through more than one proxy and `TRUSTED_PROXIES` doesn't list them, every visitor counts as one caller and shares one limit. + +**Fix.** List your proxies in `TRUSTED_PROXIES`, then run `docker compose up -d` or redeploy. See [Trusted proxies](/docs/reference/reverse-proxy#trusted-proxies). + +## Settings asks you to sign in again + +**What you see.** In Settings, `Security`, connecting or disconnecting a provider, or adding or removing a passkey, shows `Sign in again before changing how your account is accessed: this needs a session less than 15 minutes old.` + +**Why.** These changes need a sign-in less than 15 minutes old. + +**Fix.** Sign out, sign in again, and make the change right away. ## The assistant has no model -Users see `Choose a model`, or `This instance has no model configured`. Set `AI_BASE_URL` and `AI_MODEL`, then redeploy (on Coolify, [add them to the compose file](/docs/reference/configuration#add-a-variable-on-coolify) first). See [Turn on the assistant](/docs/use/assistant#turn-on-the-assistant-operator). +Users see `Choose a model`, or `This instance has no model configured`. Set `AI_BASE_URL` and `AI_MODEL`, then redeploy. On Coolify, [add them to the compose file](/docs/reference/configuration#add-a-variable-on-coolify) first. See [Assistant](/docs/reference/configuration#assistant). ## The wrong account became the operator The first account to sign in becomes the operator. If you created both accounts, move the role to yours. Replace `you@example.com`, and check the address first: a typo leaves the instance with no operator. ```bash -docker compose exec -T postgres psql -U postgres -d freenary -c "UPDATE \"user\" SET role = CASE WHEN email = 'you@example.com' THEN 'OPERATOR'::\"UserRole\" ELSE 'MEMBER'::\"UserRole\" END;" +docker compose exec -T postgres psql -U postgres -d freenary -c "UPDATE \"user\" SET role = CASE WHEN lower(email) = lower('you@example.com') THEN 'OPERATOR'::\"UserRole\" ELSE 'MEMBER'::\"UserRole\" END;" ``` If a stranger signed in first, treat the instance as compromised: remove it together with its database volume, and install again. ## Read the logs -```bash -docker compose logs -f server -``` +| Command | Shows | +| --- | --- | +| `docker compose logs -f` | Every container, live | +| `docker compose logs --tail 200 server` | The last 200 server lines | +| `docker compose logs --since 15m server` | The last 15 minutes | +| `docker compose logs migrate` | What the database update did | +| `docker compose logs web` | Requests the web app couldn't forward to the server | -| Command | Shows | -| ---------------------------------------- | ---------------------------- | -| `docker compose logs -f` | Every container, live | -| `docker compose logs --tail 200 server` | The last 200 server lines | -| `docker compose logs --since 15m server` | The last 15 minutes | -| `docker compose logs migrate` | What the database update did | +A healthy server logs `Server is running on` followed by its address. Docker keeps logs without limit by default. To cap them, add this under each service in `docker-compose.yml`: diff --git a/apps/fumadocs/content/docs/next/run/updates-and-backups.mdx b/apps/fumadocs/content/docs/next/run/updates-and-backups.mdx index 2a7d25b..bbe1970 100644 --- a/apps/fumadocs/content/docs/next/run/updates-and-backups.mdx +++ b/apps/fumadocs/content/docs/next/run/updates-and-backups.mdx @@ -1,137 +1,128 @@ --- title: Updates and backups -description: Update Freenary to a newer version, back up and restore your database, and keep your instance healthy. +description: Pick a Freenary version, update to a newer one, and back up and restore your database. icon: DatabaseBackup --- -Updating Freenary means pulling newer images and redeploying. Database changes apply on their own. Back up first, because they can't be undone. +Run Compose commands beside your `docker-compose.yml`. -## Back up before you update +## Create a backup -Your whole instance is two things: - -- **The database** — every account, transaction, plan, and the credentials you typed into the setup screen. -- **Your secrets** — `BETTER_AUTH_SECRET` and `POSTGRES_PASSWORD`, in your `.env` file or your hosting panel's environment settings. Coolify names them `SERVICE_HEX_64_AUTHSECRET` and `SERVICE_PASSWORD_POSTGRES`. - -Run the commands on this page from the folder that holds your `docker-compose.yml`. +The PostgreSQL database contains user data and any provider credentials entered through the setup screen. Export it in custom format: ```bash docker compose exec -T postgres pg_dump -U postgres -d freenary -Fc > freenary.dump ``` -Keep `-T`, so Docker doesn't attach a terminal to the command. - - - -Store `BETTER_AUTH_SECRET` with every dump. A database restored under a different secret signs everyone out, breaks two-factor for every user, and loses the bank and email credentials saved in the setup screen. - - - -Freenary doesn't back itself up. Schedule the dump command with cron or your hosting panel, and copy the file off the server. +`-T` prevents Docker from attaching a terminal to the binary output. -### With a hosting panel - -Your panel names the database container. Find it, then dump through it: +On a hosting panel, first identify its PostgreSQL container. Then run the dump inside that container: ```bash docker ps --filter name=postgres docker exec pg_dump -U postgres -d freenary -Fc > freenary.dump ``` -Replace `` with the name from the first command. Your secrets are in the panel's environment settings for the app. +Automate this command with cron or your panel because Freenary has no built-in backup schedule. Move each dump to storage outside the server. -## Update + -`FREENARY_VERSION` picks which image version runs. It's `latest` by default, which follows each new release. +Archive `BETTER_AUTH_SECRET` with each dump. In Coolify, its name is `SERVICE_HEX_64_AUTHSECRET`. Restoring with another value makes sealed settings unreadable and has other authentication costs. See [Security](/docs/reference/security). - + - +Also retain the dump's `POSTGRES_PASSWORD`. Coolify calls it `SERVICE_PASSWORD_POSTGRES`. -1. In Dokploy or Coolify, open your Freenary app's environment settings. -2. Keep `FREENARY_VERSION=latest`, or set the version you want, such as `1.2.0`. -3. Redeploy the app. +## Select an image tag -If the app still runs the old version after a redeploy on `latest`, your panel reused the image it already had. Set an exact version instead, such as `1.2.0`, and redeploy. +`FREENARY_VERSION` controls the server and web image tags. No value means `latest`. - +| Value | Image selected | +| ------------- | -------------------------------------------------------- | +| `latest` | Most recent stable release | +| `1.2.0` | Exactly that release | +| `1.2` | Most recent release in the 1.2 series | +| `1` | Most recent release in the 1 series | +| `1.3.0-rc.1` | Exactly that pre-release; pre-releases get no moving tag | +| `main`, `dev` | Current build of that branch | +| `sha-1a2b3c4` | One immutable branch build | + +Use a complete version such as `1.2.0` when you want updates only after an explicit change. + +## Deploy an update + +Create a backup before moving forward. Database migrations aren't designed to run backward. + + -Keep or change `FREENARY_VERSION` in your `.env` file, then pull and restart: +Set `FREENARY_VERSION` in `.env`, then fetch and recreate the services: ```bash -docker compose pull && docker compose up -d +docker compose pull +docker compose up -d ``` -Your database container and its data stay as they are. +Don't substitute `docker compose restart`. It neither fetches a new image nor applies changed environment values. + + + + + +Open the app's environment settings in Dokploy or Coolify. Change `FREENARY_VERSION`, or leave it at `latest`, and redeploy. -On every start, a one-time `migrate` container updates the database, then the server starts. In a panel, check that the containers are running and healthy. With Docker Compose: +Each `docker compose up` runs the one-shot `migrate` service before the API server. Confirm the result with: ```bash docker compose ps -a ``` -`migrate` should show `Exited (0)`; `postgres`, `server` and `web` should show `healthy`. Then sign in and open Budget. - -### Version tags +Expect `migrate` to report `Exited (0)`. `postgres`, `server` and `web` should become `healthy`. A hosting panel exposes the same statuses in its container view. Finally, sign in and load Budget. -| Tag | What you get | -| ------------------- | ---------------------------------------------------- | -| `latest` | The newest release. The default | -| `1.2.0`, `1.2`, `1` | A specific release, or the newest within that series | -| `main`, `dev` | The latest code on that branch. Not for everyday use | -| `sha-1a2b3c4` | One exact build. Never changes | +## Restore over a live database -Pin a version, such as `1.2.0`, if you want to choose when updates happen. +Use the environment that belongs to the dump. Stop the application first, so no request writes while the restore replaces database objects and rows: -## Roll back an update +```bash +docker compose stop web server +docker compose exec -T postgres pg_restore -U postgres -d freenary --clean --if-exists --no-owner < freenary.dump +docker compose up -d +``` -Database changes only go forward, so going back to an older version means restoring the dump you took before updating. Follow [Restore a backup](#restore-a-backup), and set `FREENARY_VERSION` back to the version you ran before the last step. +For a hosting panel, stop the server and web containers, run the restore with `docker exec -i ` in place of `docker compose exec -T postgres`, then redeploy. -## Restore a backup +## Restore into an empty database -This replaces the current database with the dump. Stop the apps, empty the database, load the dump, then start again: +On a new server, place the dump beside the Compose file and restore before starting the application services: ```bash -docker compose stop web server -docker compose exec -T postgres dropdb -U postgres --force freenary -docker compose exec -T postgres createdb -U postgres freenary -docker compose exec -T postgres pg_restore -U postgres -d freenary < freenary.dump +docker compose up -d --wait postgres +docker compose exec -T postgres pg_restore -U postgres -d freenary --no-owner < freenary.dump docker compose up -d ``` -With a hosting panel, stop the `web` and `server` services in the panel, run the three database commands with `docker exec -i ` in place of `docker compose exec -T postgres`, then redeploy. - -Make sure `BETTER_AUTH_SECRET` is the one that matches the dump, then sign in to check your data is back. - -## Keep it healthy +Use the dump's `BETTER_AUTH_SECRET` and `POSTGRES_PASSWORD`, then sign in and inspect your data. -Never run `docker compose down -v`. The `-v` deletes the volume that holds your database. +Don't run `docker compose down -v` unless you intend to erase the instance. `-v` removes the PostgreSQL volume. -- **After changing `.env`**, run `docker compose up -d`, not `restart`. A restart keeps the old values. With a panel, redeploy. -- **Free disk space** after updates by removing old images: `docker image prune -f`. -- **Watch disk use.** The database grows with your transaction history; `docker system df -v` shows sizes. -- **Cap log size.** Docker keeps container logs without limit by default. See [Troubleshooting](/docs/run/troubleshooting#read-the-logs). -- **Test a restore** once in a while, on a copy, so you know your dumps work. - diff --git a/apps/fumadocs/content/docs/next/use/account.mdx b/apps/fumadocs/content/docs/next/use/account.mdx index 183dbd1..82b5f15 100644 --- a/apps/fumadocs/content/docs/next/use/account.mdx +++ b/apps/fumadocs/content/docs/next/use/account.mdx @@ -1,116 +1,89 @@ --- title: Your account -description: Your first steps after signing in, the ways to sign in, how to protect your account, and what each Settings pane holds. +description: Your first steps, how you sign in, how to protect your account, and your language and theme. icon: UserRound --- -This page covers your own account: getting started, signing in, keeping it safe, and changing Freenary's language and look. +Set up your account the first time you sign in, protect it, and choose how Freenary looks. Everything here happens in the app, from the sign-in screen or Settings. ## First steps -The first time you sign in, Freenary asks two short questions: +The first time you sign in, Freenary asks two questions: -1. **Your tax residency.** Pick every country you pay tax in. It decides which banks you can connect and how transactions get sorted. France has full support; other countries are marked `Limited support`, so fewer transactions get a category automatically. -2. **Your bank.** Connect a bank now, or press `Skip for now` and do it later from Settings. +1. **Your tax residency.** Pick every country you pay tax in. It sets the banks you can connect. Countries other than France carry a `Limited support` badge. +2. **Your bank.** Connect one now, or press `Skip for now` and do it later from Settings. If your instance can't link banks yet, Freenary skips this question. -If you're the operator, the [setup screen](/docs/get-started/connect-a-bank) opens before these questions. + -Then a **First steps** checklist appears at the bottom of the sidebar. Each item links to the right place: +No screen changes your tax residency later, so pick every country before you go on. + + + +A **First steps** checklist then sits at the bottom of the sidebar. Each item opens the right Settings pane: | Item | Done when | | --- | --- | -| `Connect your bank` | You have at least one [bank connection](/docs/use/bank-connections) | -| `Set up your budget` | Your [plan](/docs/use/budget#plan-a-monthly-amount) has at least one line | +| `Connect your bank` | You have a [bank connection](/docs/use/bank-connections) | +| `Set up your budget` | Your [plan](/docs/use/budget#plan-a-budget) has a line | | `Protect your account` | You turn on two-factor authentication or add a passkey | -The checklist disappears once all three are done. +The checklist goes away once all three are done, and comes back if one stops being true, for example when you remove your only passkey. ## Sign in -Type your email address on the sign-in screen. Freenary checks it, then asks for your password, or for a name and password if you're new. A new password needs at least 12 characters. +Type your email address on the sign-in screen. Freenary then asks for your password, or for a name and a password if you're new. Password and code rules are in [Sign-in](/docs/run/sign-in). -Depending on how your instance is set up, you may also see: +Your operator decides what else the screen offers: -- **A confirmation code.** On an instance that sends email, a new account gets a 6-digit code by email. It's valid for 10 minutes. No code? Press `Send a new code` and check your spam folder. -- **Forgot password?** It emails you a code to choose a new password, then you sign in with it. This link only appears on an instance that sends email. -- **Sign in with a passkey** — use your device's fingerprint, face or PIN instead of a password. -- **Continue with Google**, **Apple** or **single sign-on**, if your operator turned them on. +- **A confirmation code** by email for a new account, on an instance that sends email. No code? Press `Send a new code` and check your spam folder. +- **`Sign in with a passkey`**: your device's fingerprint, face or PIN instead of a password. +- **`Continue with Google`**, **`Continue with Apple`**, **`Continue with single sign-on`**, or `Continue with` followed by another provider's name. -You stay signed in for 7 days on each browser. +### Change your password - +Freenary has no screen to change your password. Use `Forgot password?` on the sign-in screen: it emails you a code, and you choose a new password with it. -Your operator decides which methods exist and whether Freenary sends email. If a code never arrives or there's no `Forgot password?` link, ask them. Operator details: [Sign-in](/docs/run/sign-in) and [Email](/docs/run/email). +That link only appears on an instance that sends email. Without it, ask your operator. - +Freenary also has no screen to change your name or email address, or to delete your account. ## Protect your account -Do at least one of these, from Settings, `Security`. +Do at least one of these in Settings, `Security`. ### Turn on two-factor authentication With two-factor on, signing in with your password also asks for a code from an authenticator app. - - - - -### Start - -In `Two-factor authentication`, turn the switch on and confirm your password. - - - - +1. In `Two-factor authentication`, turn the switch on and confirm your password. +2. Scan the QR code with your authenticator app, and type the 6-digit code it shows. +3. Copy the recovery codes and keep them somewhere safe. Freenary shows them only once. -### Scan the code +Each recovery code signs you in once without your phone: at sign-in, choose `Use a recovery code`. To get a new set, press `Generate new recovery codes`; the old set stops working. -Scan the QR code with your authenticator app, then type the 6-digit code it shows. +At sign-in, `Remember this device for 30 days` skips the code on that browser. - - - - -### Save your recovery codes - -Copy the recovery codes and keep them somewhere safe. Each one works once if you lose your phone. - - - - - -At sign-in you can tick "Remember this device" to skip the code on that browser for 30 days. - -Two-factor protects password sign-in only. If you created your account with Google, Apple or single sign-on, you have no password, so add a passkey instead. +Two-factor protects password sign-in only. An account created with Google, Apple or single sign-on has no password, so add a passkey instead. ### Add a passkey -In `Passkeys`, press `Add a passkey`, name it, and follow your browser's prompt. Passkeys need a recent browser and an HTTPS address. +In `Passkeys`, press `Add a passkey`, name it, and follow your browser's prompt. Passkeys need a recent browser on a secure (HTTPS) connection. -## Settings +### The recent sign-in rule -Open Settings from the sidebar. It has four panes: - -| Pane | What you do there | -| --- | --- | -| `Appearance` | Theme and language | -| `Connections` | Connect, re-sync and disconnect banks | -| `Budget` | Your monthly plan and your categories | -| `Security` | Two-factor, passkeys, signed-in browsers, and linked Google, Apple or single sign-on accounts | +Adding or removing a passkey, and linking or unlinking Google, Apple or single sign-on, need a sign-in from the last 15 minutes. Otherwise Freenary says `Sign in again before changing how your account is accessed: this needs a session less than 15 minutes old.` Sign out, sign back in, and try again. -In `Active sessions` you can sign out a single browser with `Revoke`, or all others at once with `Sign out other sessions`. +## Sessions -Freenary has no screen to change your name or email address, or to delete your account. To change your password, use `Forgot password?` on the sign-in screen. +`Active sessions`, in Settings, `Security`, lists every browser signed in to your account. Press `Revoke` to sign one out, or `Sign out other sessions` to sign out every browser but this one. How long a session lasts is in [Sign-in](/docs/run/sign-in#sessions). ## Language and theme -Change them from Settings, `Appearance`, or from the menu under your name at the bottom of the sidebar. Both are also on the sign-in screen. +Freenary speaks English and French, and has a light theme, a dark theme, and one that follows your system. -- **Language:** English or Français. The page reloads, so save any unsaved plan first. -- **Theme:** `System` (follows your device), `Dark` or `Light`. +Change them in Settings, `Appearance`, from the menu under your name at the bottom of the sidebar, or on the sign-in screen. Changing the language reloads the page, so save an unsaved plan first. -Both choices belong to the browser, not to your account. +Both choices belong to the browser, not to your account: another browser keeps its own. diff --git a/apps/fumadocs/content/docs/next/use/assistant.mdx b/apps/fumadocs/content/docs/next/use/assistant.mdx index e2991b6..43160ea 100644 --- a/apps/fumadocs/content/docs/next/use/assistant.mdx +++ b/apps/fumadocs/content/docs/next/use/assistant.mdx @@ -1,66 +1,68 @@ --- title: Assistant -description: Ask questions about your money in plain words, see how each answer was found, and turn the assistant on as an operator. +description: Ask questions about your money in plain words, pick the model that answers, and know what data goes where. icon: Sparkles --- -The assistant is the question box on Home. Ask it about your accounts, spending or budget, and it answers from the data your banks sent to Freenary. - -Connect a bank first, or the assistant has little to read. See [Bank connections](/docs/use/bank-connections). +The assistant is the question box on Home. Ask it about your accounts, spending or budget, and it answers from the data your banks sent to Freenary. Connect a [bank](/docs/use/bank-connections) first, or it has little to read. ## Ask a question -Type your question on Home and send it. For example: +Type your question on Home and send it, for example: - How much did I spend on eating out this year? - Where did my money go last month? - Which subscriptions am I paying for? -On a new conversation, Freenary suggests a few questions. Pick one to fill the box, edit it if you like, then send. +An empty conversation offers these as suggestions. Pick one to fill the box, edit it, then send. -## Read an answer +Above each answer, Freenary lists the lookups it made, such as `Searching your transactions`. Open `How the assistant answered` to see where a figure came from. Some answers include a chart. Use `Copy` to copy an answer and `Retry` to ask again. -Above each answer, Freenary lists the steps it took, such as `Searching your transactions` or `Comparing your plan with what you spent`. Press the header to open them and check where a figure came from. +The assistant only reads. It doesn't change a category or a budget line, doesn't start a sync, and never moves money. -Some answers include a chart. Use `Copy` to copy an answer and `Retry` to ask again. +## Pick a model -Press `New conversation` to start over. You have one conversation at a time; the previous one isn't kept in a list. +The model button in the question box shows which model answers. It lists two kinds: -## What it can't do +- **`On your instance`**: a model your operator set up. Nothing to download. +- **`On this device`**: a model that runs in your browser, on your graphics card. It downloads once from Hugging Face, and needs WebGPU, which a current Chrome, Edge or Safari has. Answers are simpler than a large hosted model's. -The assistant only reads. It doesn't change categories or budget lines, doesn't start a sync, and never moves money. +If the box says `Choose a model` and stays greyed out, your instance has no model. Pick one for your device, or ask your operator to turn the assistant on. -## Pick a model +## What data goes where -The model button in the question box shows which model answers. +To answer, the model receives your question, the earlier turns of the conversation, today's date, your interface language, the dates your transaction data covers, and what it looked up: -- **A model on your instance** — set up by your operator. It works straight away. -- **A model on this device** — if your browser supports WebGPU (a current Chrome, Edge or Safari), you can pick a model that runs on your own graphics card. It downloads once, and your questions stay in that browser. Answers are simpler than with a large hosted model. +- your account names and their banks; +- up to 20 transactions per lookup: date, amount, currency, the bank's label, the other party and the category; +- spending and cash-flow totals, your plan against what you spent, and the recurring payments Freenary found. -If the button says `Choose a model` and the box is greyed out, your instance has no model. Pick one for your device, or ask your operator to turn the assistant on. +It never receives your IBAN, your account balances, your name or your email address. -## What leaves your instance +Where that goes depends on the model: -To answer, the model receives your question and the data it looked up: account names, transactions, and spending by category. Freenary never sends your IBAN. +- **A model on your instance** sends it to the model service your operator chose. That can be a company outside your instance, under its own terms. +- **A model on this device** keeps it in your browser. Nothing goes to an outside model provider. -- With a model on your instance, that data goes to the endpoint your operator chose. -- With a model on your device, everything stays in your browser. + -## Turn on the assistant (operator) +Your conversation is saved on your instance with either kind of model, so it's there after a reload and in your other browsers. -Freenary ships without a model. To give everyone a model on the instance, point Freenary at any OpenAI-compatible endpoint: a hosted gateway such as OpenRouter, or a runtime on your own hardware such as Ollama. + -Set `AI_BASE_URL` and `AI_MODEL`, plus `AI_API_KEY` if the endpoint needs a key, in your instance's environment and redeploy. They aren't in the setup screen. [Configuration](/docs/reference/configuration#assistant) has examples, and on Coolify you first add them to the compose file, see [Add a variable on Coolify](/docs/reference/configuration#add-a-variable-on-coolify). +## Too many questions - +You can ask about 30 questions every 5 minutes, whatever the model. Past that, the assistant says `Too many questions in a row. Wait a moment and try again.` -**Pick a model that supports tool calling.** The assistant reads your data through tool calls. A model without them still answers, but invents every figure, and no error tells you. After setup, ask a question that needs a number and check the answer. +With a model on this device, the answer still appears, but Freenary doesn't save it to your conversation. - +## Start a new conversation + +Press `New conversation` to start over. You have one conversation at a time: the previous one stays on your instance, but no screen shows it again. -With a hosted gateway, the data in each question goes to that provider under its terms. A runtime on your own hardware keeps it on your server. +A long conversation can outgrow a model on this device. When that happens, the assistant asks you to start a new conversation. -Each account can ask about 30 questions per 5 minutes. +Operators turn on a model for the whole instance in [Configuration](/docs/reference/configuration#assistant). diff --git a/apps/fumadocs/content/docs/next/use/bank-connections.mdx b/apps/fumadocs/content/docs/next/use/bank-connections.mdx index d03e3ec..31435bd 100644 --- a/apps/fumadocs/content/docs/next/use/bank-connections.mdx +++ b/apps/fumadocs/content/docs/next/use/bank-connections.mdx @@ -1,20 +1,20 @@ --- title: Bank connections -description: Connect a bank, keep it in sync, disconnect it, and fix a connection whose consent has expired. +description: Add a bank, know what arrives and when, sync it, remove it, and act on its error messages. icon: Landmark --- -A bank connection links Freenary to one bank. Your accounts and transactions arrive through it; you never type them in by hand. +A bank connection links Freenary to one bank. Your accounts and transactions arrive through it; you never type them in. -You manage connections in Settings, under `Connections`. The same list appears during the first-sign-in steps. +You manage connections in Settings, under `Connections`. The same list appears in the first-sign-in steps. -If the list says `Bank linking is unavailable`, your instance has no bank provider yet. The operator sets one up in the setup screen: see [Connect a bank](/docs/get-started/connect-a-bank). +If the list says `Bank linking is unavailable`, your instance has no bank provider yet. Ask your operator to set one up: see [Connect a bank](/docs/get-started/connect-a-bank). -## Connect a bank +## Add a bank @@ -22,9 +22,7 @@ If the list says `Bank linking is unavailable`, your instance has no bank provid ### Find your bank -Open Settings, then `Connections`. Search for your bank and press `Connect` on its row. - -The list shows banks for the countries you chose as your tax residency. +Open Settings, then `Connections`. Search for your bank and press `Connect` on its row. The list holds the banks of the countries you chose as your tax residency. @@ -32,7 +30,7 @@ The list shows banks for the countries you chose as your tax residency. ### Approve access at your bank -Your browser goes to your bank, or to the service that links it. Sign in there, confirm with your bank's usual security check, and choose which accounts to share. +Your browser goes to your bank, or to the service that links it. Sign in there, pass your bank's usual security check, and choose the accounts to share. @@ -40,84 +38,72 @@ Your browser goes to your bank, or to the service that links it. Sign in there, ### Come back to Freenary -Your bank sends you back. Freenary shows `Bank connected` and starts importing. +Your bank sends you back to Settings. Freenary says how many accounts it connected, or `Bank connected` if your bank shared none. -Freenary imports your accounts, their balances, and your transactions, each sorted into a category. Some bank providers also return investment holdings. - -If you cancel at the bank, nothing is linked. Press `Connect` again to retry. - -## Sync - -A sync fetches new transactions from your banks. There's no nightly sync: Freenary syncs when you ask. - -| When | What happens | -| --- | --- | -| You open Budget | A quiet sync, once per visit | -| You press `Sync transactions` on Budget | Every connection is read again | -| You press the re-sync button on a bank's row in Settings | That one connection is read again | - -Each sync reads at most the last **90 days**. The first sync brings in those 90 days; older transactions never arrive, and nothing widens the window. +If you cancel at your bank, Freenary says `Bank connection cancelled. Nothing was linked.` Press `Connect` again whenever you're ready. -While a sync runs, Budget shows its progress and fills in as transactions arrive. Only one sync runs at a time. +## What arrives, and when -## Disconnect a bank +Right after you connect, the bank and its accounts are in the list, marked `Not synced yet`. No transaction has arrived yet. -1. In Settings, `Connections`, press `Disconnect` on the bank's row. -2. Confirm with `Disconnect bank`. - -Freenary asks your bank to revoke its access, then deletes that connection's accounts and transactions. Your plan, custom categories and category fixes stay. - -If Freenary couldn't reach your bank to revoke access, it tells you so. Revoke it from your bank's own app or website to be sure. +Transactions arrive with the first sync: open Budget, or press the re-sync button on the bank's row. The first sync reads the last **90 days**. Anything older on the day you connect never arrives. -## Fix an expired connection - -Bank consents don't last forever. When yours runs out, or your bank wants you to confirm again, syncing that bank fails with `Failed to sync transactions` and its "Synced" date stops moving. - -There's no reconnect button. Instead: - - +While a sync runs, Budget shows its progress and fills in as transactions arrive. Freenary then sorts them into categories; [Budget](/docs/use/budget#fix-a-category) shows how to fix the ones it gets wrong. - +## Sync -### Try a re-sync +A sync fetches transactions from your banks. Freenary has no nightly sync: it syncs only when one of these happens. -Press the re-sync button on the bank's row. A temporary failure often clears here. +| You | Freenary | +| --- | --- | +| Open Budget | Runs a quiet sync of every bank. It fetches what's new since the last sync and sorts only the new transactions. Moving between Transactions and Recurring doesn't start another one | +| Press `Sync transactions` on Budget | Reads the last 90 days again from every bank, and sorts all your transactions into categories again | +| Press the re-sync button on a bank's row in Settings | Does the same for that one bank | - +Sorting again keeps every category you fixed by hand. - +When some of a bank's accounts fail, its last sync date still moves forward. Press `Sync transactions` to fill the gap. -### Disconnect the bank +One sync runs at a time. A sync you start while another is running does nothing, even when the bank's row then says it re-synced. Wait until Budget shows no sync in progress, then start it again. -If it still fails, disconnect it. Its accounts and transactions are removed with it. +## Remove a bank - +1. In Settings, `Connections`, press `Disconnect` on the bank's row. +2. Confirm with `Disconnect bank`. - +Freenary asks your bank to revoke its access, then deletes the connection with its accounts and transactions. Your plan, your custom categories and your company-wide category fixes stay. -### Connect it again + -Press `Connect` on the same bank and approve access again. This is a new connection, so the last 90 days are imported again. +If Freenary couldn't reach your bank, it says the bank was disconnected but access could not be revoked. Revoke it from your bank's own app or website. - + - +To connect the same bank again later, press `Connect` on it. It's a new connection, so it reads the last 90 days again. -## When something goes wrong +## Error messages | You see | What to do | | --- | --- | | `Bank linking is unavailable` | Ask your operator to set up a bank provider | -| `Could not check bank linking` or `Could not load banks` | Reload the page | +| `Could not check bank linking`, `Could not load banks` or `Could not load your banks` | Reload the page | +| `Freenary could not load banks for` a country | Reload the page. If it stays, ask your operator | | `No banks match your search` | Search for a shorter part of the name | -| `Bank connection failed. You can try again.` | Press `Connect` again | -| `Failed to sync transactions` | Press `Retry`. If it keeps failing, [fix the connection](#fix-an-expired-connection) | +| `Could not connect to` your bank, `Try again later.` | Wait a few minutes, then press `Connect` again | +| `Bank connection failed. You can try again.` or `The bank sent back an incomplete response.` | Press `Connect` again | +| `Failed to sync transactions` | Press `Retry` on the message, or the re-sync button on the bank's row. If it keeps failing, see below | + +### A bank keeps failing to sync + +Bank consents expire. When yours runs out, or your bank wants you to confirm access again, every sync of that bank fails with `Failed to sync transactions`, and Freenary has no reconnect button. Instead: -Operators: see [Troubleshooting](/docs/run/troubleshooting#the-bank-list-is-empty) for an empty bank list. +1. Press the re-sync button on the bank's row once more. A temporary failure often clears. +2. If it still fails, [remove the bank](#remove-a-bank). Its accounts and transactions go with it. +3. Press `Connect` on the same bank and approve access again. diff --git a/apps/fumadocs/content/docs/next/use/budget.mdx b/apps/fumadocs/content/docs/next/use/budget.mdx index cec59e8..464d2a5 100644 --- a/apps/fumadocs/content/docs/next/use/budget.mdx +++ b/apps/fumadocs/content/docs/next/use/budget.mdx @@ -1,102 +1,70 @@ --- title: Budget -description: See where your money comes from and where it goes, fix a transaction's category, and plan a monthly amount per category. +description: Read what came in and went out, fix a transaction's category, see your recurring payments, and plan a monthly budget. icon: ChartNoAxesColumn --- -Budget shows what came in and what went out over a period, sorted into categories. You can compare it with a monthly plan you set yourself. +Budget shows the money your banks report, sorted into categories, and compares it with a monthly plan you set. It fills once you have a [bank connection](/docs/use/bank-connections). -Budget fills once you have a bank connection. See [Bank connections](/docs/use/bank-connections). +Budget has two pages in the sidebar: **Transactions** and **Recurring**. -## Read your budget +## Transactions -Budget has two pages in the sidebar: +**Transactions** shows income, spending and the difference for one period, two charts, and the list of transactions behind them. -- **Transactions** — income, expenses and the difference for the period you pick, two charts, and the list of transactions. -- **Recurring** — subscriptions, rent and other payments that repeat, what they cost per month and per year, and what's due next. It always looks at your last 12 months. +### Pick a period -On **Transactions**, pick a range (`1M`, `3M` or `1Y`) and use the arrows to move between periods. On `3M` and `1Y` you can show monthly averages or medians instead of totals. +Choose `1M`, `3M` or `1Y`, then use the arrows to move to the previous or next period. On `3M` and `1Y`, the figures can show the monthly average or median instead of the total. -Press a part of a chart to filter the list to that category. Use the search box and the `Amount`, `Company` and `Category` filters to narrow it further. `Clear all` removes every filter. +### Narrow the list - +The list has two tabs: `Outgoing`, where it opens, and `Incoming`. Each tab shows the total of the transactions that match what you've narrowed it to. -The page address holds the period and every filter. Copy it to bookmark or share exactly what you see. +- Type in the search box to match the bank's label or the other party's name. +- Use the `Amount`, `Company` and `Category` filters. +- Press a part of a chart to filter the list to that category. - - -## Get the latest transactions - -Freenary syncs your banks when you open Budget. To fetch again, press `Sync transactions`. Transactions appear while the sync runs. - -A sync reads at most the last 90 days. Older transactions never arrive. See [Bank connections](/docs/use/bank-connections#sync). - -## Change a transaction's category +With two or more filters on, `Clear all` removes them. It leaves the search text. -Freenary sorts each transaction into a category for you. When it gets one wrong: +The page address holds the period and every filter, so a bookmark brings back exactly what you see. - +### Get the latest transactions - +Opening Budget fetches what's new. To read your banks again in full, press `Sync transactions`. [Bank connections](/docs/use/bank-connections#sync) says what each sync reads. -### Open the transaction +## Fix a category -Press the row in the list. A `Transaction details` panel opens. +Freenary sorts each transaction into a category. When it gets one wrong: - +1. Press the transaction's row. A panel opens with its details. +2. Pick the right category. Freenary saves at once. - +The picker only offers categories that match the transaction's direction, money in or money out. It offers the built-in categories only: [custom categories](#add-a-custom-category) are for budget lines, and no transaction can be filed under one. -### Pick the right category +**Your choice applies to the whole company.** Every other transaction from the same company moves to your category, and so do the ones that arrive later. Two kinds of transaction keep their own category: -Open the category picker and choose. Freenary saves at once. +- a transaction you already fixed by hand; +- a cash withdrawal, a cheque or a bank fee that arrives later. - - - - -Your choice applies to the company, not only to that one transaction. Other transactions from the same company move to your category, and so do future ones. Transactions you already fixed by hand keep their category. - -To undo, press the reset button next to the picker. It puts back the category Freenary chose. - - - -You can only change the category. Freenary has no way to add, edit or delete a transaction: everything comes from your bank. The picker only offers the built-in categories, not your custom ones. - - +A transaction Freenary can't tie to a company changes alone. -## Know the categories +**Reset undoes it.** A transaction whose category differs from Freenary's shows a reset button next to the picker. Pressing it puts this transaction and the company's other transactions back to Freenary's category, except the ones you fixed by hand. Transactions that arrived after your choice keep it until you press `Sync transactions`. -Every category belongs to one of three fixed groups: +You can change a category, nothing else. Freenary has no way to add, edit or delete a transaction: everything comes from your bank. -| Group | What it holds | -| --- | --- | -| Income | Money that comes in: salary, refunds, benefits… | -| Savings & investment | Money you put aside: savings, investments, retirement, crypto | -| Spending | Money you spend: groceries, rent, bills, transport… | +### Internal transfers -A category takes the colour of its group. To see the full list, open Settings, then `Budget`, then `Categories`. +When the same amount in the same currency leaves one of your accounts and reaches another within a day, Freenary marks the pair as an internal transfer. It doesn't give them a category, and Recurring leaves them out. -Money moved between two of your own accounts is recognised as an internal transfer, so it doesn't count twice. +On **Transactions**, internal transfers still count: they're in the totals, the charts and the list. -## Add a custom category +## Recurring -Custom categories let you organise your plan the way you think about it, for example "Holidays" or "Kids". +**Recurring** lists the payments that repeat, such as rent and subscriptions: what each one costs per month and per year, and when the next one is due. It always looks at your last 12 months, whatever period you picked on **Transactions**. -1. Open Settings, then `Budget`. -2. In `Categories`, press `New category`. -3. Give it a name and an icon, and choose where it sits: under a group, under one of your own categories, or on its own. -4. Press `Create category`. +## Plan a budget -You can nest one level deep: a custom category under another custom category, but no further. A custom category under a group takes that group's colour; one on its own gets a colour you pick. - -You can edit, reorder or delete your own categories. Deleting one moves its budget lines to a catch-all category. The built-in categories can't be changed. - -Custom categories are for budget lines only. You can't file a transaction under one. - -## Plan a monthly amount - -Your plan is a list of budget lines: one monthly amount per category. Freenary compares it with what you actually spent. +Your plan is a list of budget lines, each one a monthly amount for one category. @@ -104,7 +72,7 @@ Your plan is a list of budget lines: one monthly amount per category. Freenary c ### Open the plan -Open Settings, then `Budget`. The plan sits under `Budgeting profile`. +Open Settings, then `Budget`. The plan is under `Budgeting profile`. @@ -112,9 +80,7 @@ Open Settings, then `Budget`. The plan sits under `Budgeting profile`. ### Add your lines -Press `Add a line`. Pick a category, type the monthly amount in euros, and add a name if you like. Repeat for each thing you want to plan. - -The category decides whether a line counts as income, savings or spending. +Press `Add a line`, pick a category, type the monthly amount in euros, and name the line if you like. The category decides whether the line counts as income, savings or spending. @@ -128,15 +94,42 @@ A bar appears at the bottom of the page. Press `Save`. -A diagram above the editor shows how your planned income flows into each line, and how much is left or over-allocated. +A diagram above the editor shows how your planned income flows into each line, and what's left or over-allocated. A plan holds up to 200 lines. + + + +Leaving the page before you press `Save` loses your changes. The browser warns you when you close or reload the tab, but not when you go to another page in Freenary. + + + +### Add a custom category + +A custom category lets you plan the way you think, for example "Holidays" or "Kids". + +1. In Settings, `Budget`, `Categories`, press `New category`. +2. Give it a name and an icon, and choose where it sits in `Nested under`: a group, one of your own categories, or nothing. +3. Press `Create category`. + +Nesting goes one level deep. You can edit, reorder and delete your own categories; the built-in ones can't change. Deleting a custom category also deletes its subcategories, and moves their budget lines to a catch-all: `Other income` under Income, `Savings` under Savings & investment, and `Other` everywhere else. + +### Compare plan and actual + +On **Transactions**, open the chart menu and choose `Budget vs. actual`. Each category shows what you spent against what you planned, and how much is left or over. + +- Income lines aren't compared. +- Spending in a category with no line shows as `Unplanned`. +- A line on a custom category counts toward its group's catch-all: `Other` or `Savings`. +- With the total shown, the plan is multiplied by the number of months of the period that have passed so far. + +With no spending line yet, the chart offers `Set a spending plan`, which opens the editor. -Your plan holds up to 200 lines. Save before you switch language or leave the page, or you lose your changes. +## How amounts appear -## Compare plan and actual +Each transaction shows in its own currency, in the list and in its details panel. -On **Transactions**, open the chart menu on the right and choose `Budget vs. actual`. Each category shows what you spent against what you planned, and whether you're over or have some left. +On **Transactions**, the totals, the tabs and the charts are in euros. On **Recurring**, the figures are in the currency most of your accounts use, and each row keeps its own. -With no plan yet, the chart offers `Set a spending plan`, which opens the plan editor. +Freenary doesn't convert between currencies. An amount in another currency is added to a total as it is: 100 US dollars counts as 100 euros. { ); } + for (const phrase of FILLER_PHRASES) { + flag( + phrase, + "no-filler", + (hit) => + `\`${hit}\` is filler or a sales word. Delete it, or state the concrete fact it stands in for.` + ); + } + if (isUserPage(relativePath)) { flag( ENV_VAR_OR_ENUM_NAME, diff --git a/apps/fumadocs/scripts/docs-rules.ts b/apps/fumadocs/scripts/docs-rules.ts index 89bc7f4..16e3c8d 100644 --- a/apps/fumadocs/scripts/docs-rules.ts +++ b/apps/fumadocs/scripts/docs-rules.ts @@ -57,6 +57,16 @@ export const ROADMAP_PHRASES: readonly RegExp[] = [ /\bwill be (?:added|supported|available)\b/gi, ]; +export const FILLER_PHRASES: readonly RegExp[] = [ + /\bit(?:'s| is) (?:important|worth) (?:to note|noting)\b/gi, + /\bnote that\b/gi, + /\blet's dive in\b/gi, + /\bthis (?:page|section|guide) (?:will )?(?:explores?|covers?|walks? you through)\b/gi, + /\bin (?:summary|conclusion)\b/gi, + /\b(?:seamless(?:ly)?|robust|powerful|revolutionary|cutting-edge|effortless(?:ly)?|comprehensive)\b/gi, + /\bsimply (?=(?:run|click|press|open|add|type)\b)/gi, +]; + export const ENV_VAR_OR_ENUM_NAME = /\b[A-Z][A-Z\d]*(?:_[A-Z\d]+)+\b/g; export const LONG_SENTENCE_WORD_LIMIT = 35; diff --git a/apps/fumadocs/scripts/moving-refs.ts b/apps/fumadocs/scripts/moving-refs.ts index 3096169..5cc5b98 100644 --- a/apps/fumadocs/scripts/moving-refs.ts +++ b/apps/fumadocs/scripts/moving-refs.ts @@ -27,6 +27,13 @@ export const MOVING_REFS: readonly MovingRef[] = [ pinned: ({ tag }) => repoRawUrl(tag), rule: "link", }, + { + message: () => + `a deploy step names the branch \`${gitConfig.branch}\`, which holds unreleased files. A released page deploys \`${gitConfig.releaseBranch}\`.`, + moving: `Branch: \`${gitConfig.branch}\``, + pinned: () => `Branch: \`${gitConfig.releaseBranch}\``, + rule: "link", + }, { message: (folder) => `\`FREENARY_VERSION=${gitConfig.branch}\` names the moving branch. A released page pins it: \`FREENARY_VERSION=${folder}\`.`, diff --git a/apps/fumadocs/src/app/routes/docs/$.tsx b/apps/fumadocs/src/app/routes/docs/$.tsx index 55ae769..8de51fc 100644 --- a/apps/fumadocs/src/app/routes/docs/$.tsx +++ b/apps/fumadocs/src/app/routes/docs/$.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { DocumentationPage, loadDoc } from "@/pages/docs"; +import { appName } from "@/shared/config"; import { docs } from "@/shared/content"; const DocsRoute = () => ; @@ -14,4 +15,12 @@ export const Route = createFileRoute("/docs/$")({ return loaded; }, + head: ({ loaderData }) => ({ + meta: loaderData + ? [ + { title: `${loaderData.title} | ${appName}` }, + { name: "description", content: loaderData.description }, + ] + : [], + }), }); diff --git a/apps/fumadocs/src/pages/docs/api/load-doc.ts b/apps/fumadocs/src/pages/docs/api/load-doc.ts index e3ac884..8a4a736 100644 --- a/apps/fumadocs/src/pages/docs/api/load-doc.ts +++ b/apps/fumadocs/src/pages/docs/api/load-doc.ts @@ -14,6 +14,8 @@ export type NewestReleaseLink = { export interface LoadedDoc { path: string; + title: string; + description: string; markdownUrl: string; pageTree: SerializedPageTree; version: string; @@ -56,6 +58,8 @@ export const loadDoc = createServerFn({ return { path: page.path, + title: page.data.title, + description: page.data.description, markdownUrl: encodeMarkdownUrl(page.slugs, page.locale), pageTree: await source.serializePageTree(source.getPageTree()), version, diff --git a/apps/fumadocs/src/shared/config/site.ts b/apps/fumadocs/src/shared/config/site.ts index 29a7365..8425e7d 100644 --- a/apps/fumadocs/src/shared/config/site.ts +++ b/apps/fumadocs/src/shared/config/site.ts @@ -5,7 +5,8 @@ export const docsImageRoute = "/og/docs"; export const gitConfig = { user: "suiramdev", repo: "freenary", - branch: "main", + branch: "dev", + releaseBranch: "main", }; export const repoBlobUrl = (ref: string) => diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 736da15..c0db0bc 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -43,7 +43,7 @@ "assistant_model_device_group": "On this device", "assistant_model_label": "Model", "assistant_model_none": "No model matches.", - "assistant_model_none_chosen": "Choose a model to start. It downloads once from Hugging Face, runs on your graphics card, and your questions stay in this browser.", + "assistant_model_none_chosen": "Choose a model to start. It downloads once from Hugging Face and runs on your graphics card. Your questions are never sent to an outside model provider, and the conversation is saved on this instance.", "assistant_model_search": "Search models…", "assistant_model_server_group": "On your instance", "assistant_new_conversation": "New conversation", @@ -687,7 +687,7 @@ "onboarding_continue": "Continue", "onboarding_contribute_link": "Contribute here", "onboarding_contribute_prompt": "Want to add support for your country?", - "onboarding_country_description": "Select every jurisdiction you are subject to. They set the banks you can link and the categorization rules we apply.", + "onboarding_country_description": "Select every jurisdiction you are subject to. They set the banks you can link.", "onboarding_country_empty": "No countries match your search", "onboarding_country_partial_badge": "Limited support", "onboarding_country_search_placeholder": "Add a country…", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 27f33e5..8d32f3e 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -43,7 +43,7 @@ "assistant_model_device_group": "Sur cet appareil", "assistant_model_label": "Modèle", "assistant_model_none": "Aucun modèle ne correspond.", - "assistant_model_none_chosen": "Choisissez un modèle pour commencer. Il se télécharge une fois depuis Hugging Face, s’exécute sur votre carte graphique et vos questions restent dans ce navigateur.", + "assistant_model_none_chosen": "Choisissez un modèle pour commencer. Il se télécharge une fois depuis Hugging Face et s’exécute sur votre carte graphique. Vos questions ne sont jamais envoyées à un fournisseur de modèles externe, et la conversation est enregistrée sur cette instance.", "assistant_model_search": "Rechercher un modèle…", "assistant_model_server_group": "Sur votre instance", "assistant_new_conversation": "Nouvelle conversation", @@ -696,7 +696,7 @@ "onboarding_continue": "Continuer", "onboarding_contribute_link": "Contribuez ici", "onboarding_contribute_prompt": "Vous souhaitez ajouter la prise en charge de votre pays ?", - "onboarding_country_description": "Sélectionnez toutes les juridictions dont vous dépendez. Elles déterminent les banques que vous pouvez connecter et les règles de catégorisation appliquées.", + "onboarding_country_description": "Sélectionnez toutes les juridictions dont vous dépendez. Elles déterminent les banques que vous pouvez connecter.", "onboarding_country_empty": "Aucun pays ne correspond à votre recherche", "onboarding_country_partial_badge": "Prise en charge limitée", "onboarding_country_search_placeholder": "Ajouter un pays…", diff --git a/deploy/coolify.yml b/deploy/coolify.yml index 9abb500..8cd81e7 100644 --- a/deploy/coolify.yml +++ b/deploy/coolify.yml @@ -1,5 +1,5 @@ # documentation: https://freenary.dev/docs/get-started/install -# slogan: Self-hosted personal finance: budgets, goals and bank sync on your own server. +# slogan: Self-hosted personal finance: bank sync, categories and budgets on your own server. # category: finance # tags: finance,budget,banking,money,personal-finance # logo: svgs/freenary.svg diff --git a/deploy/dokploy/meta.json b/deploy/dokploy/meta.json index df4354c..f58650e 100644 --- a/deploy/dokploy/meta.json +++ b/deploy/dokploy/meta.json @@ -2,7 +2,7 @@ "id": "freenary", "name": "Freenary", "version": "latest", - "description": "Self-hosted personal finance: budgets, goals and bank sync on your own server.", + "description": "Self-hosted personal finance: bank sync, categories and budgets on your own server.", "logo": "freenary.svg", "links": { "github": "https://github.com/suiramdev/freenary", diff --git a/docs/audit/docs-plan.md b/docs/audit/docs-plan.md new file mode 100644 index 0000000..44dd1e5 --- /dev/null +++ b/docs/audit/docs-plan.md @@ -0,0 +1,103 @@ +# Documentation plan + +Plan for the full rewrite of `apps/fumadocs/content/docs/next` (2026-10-17). Every page is written again from an evidence inventory built from the code, not from the previous pages. `docs-review.md` holds the review findings, `docs-validation.md` what was run. + +## Readers + +| Reader | Section | Goal | +| --- | --- | --- | +| Someone with a server and no instance | `get-started/` | A running instance, the operator account, one linked test bank | +| Anyone signed in | `use/` | What each screen does and how to fix what it shows. No terminal | +| The operator after install | `run/` | Email, sign-in options, updates, backups, failures | +| Operators and developers | `reference/` | Exact variables, provider limits, proxy setups, security model, API | +| Contributors | `contribute` | Run the code, pass the checks, open a pull request | + +## Framework + +Fumadocs on TanStack Start stays. It already gives Markdown sources, `meta.json` navigation, local search, syntax highlighting, versioned folders and two gates (`docs:check`, `build`). A new framework would add risk and no reader value. + +## Evidence + +Six read-only inventories, one per area, list every fact with a `path:line` or the command that showed it, and separate verified facts, inferences and unknowns. They are the only source the writers used. + +| Area | Main sources | +| --- | --- | +| Product and install | `apps/web/src/app/shell/nav-items.ts`, `docker-compose.yml`, `deploy/**`, `.github/workflows/images.yml`, `packages/instance-config/src/claim.ts`, `apps/web/src/pages/{setup,onboarding}/**` | +| Signed-in screens | `apps/web/src/pages/**`, `features/**`, `packages/api/src/routers/**`, `lib/bank-sync.ts`, `assistant/**`, `en.json`, `fr.json` | +| Operating | `packages/email/**`, `packages/auth/**`, `seal.ts`, `forward-api-request.ts`, compose `:?` messages; backup and restore run against a throwaway PostgreSQL | +| Platform | `packages/env/src/schema*.ts`, `scripts/env-sync.ts`, `packages/instance-config/**`, `providers/**`, `classifier/**`, `cookies.ts`, Dockerfiles | +| API | `apps/server/src/index.ts`, all routers; responses recorded from a running server | +| Contributing | `package.json` files, `turbo.json`, `lefthook.yml`, `ci.yml`, `release.yml`, `scripts/dev.ts`; local path run on a scratch worktree | + +## Sitemap + +The 22 slugs stay, so no URL moves and `moved-pages.ts` needs no pair. Every page is rewritten. Diátaxis fit: `get-started/` is the tutorial, `use/` and `run/` are how-to guides, `reference/` is reference, and `security` and `scaling` carry the explanation the operator needs. + +| Page | Reader goal | +| --- | --- | +| `index` | Decide whether Freenary fits, and where to start | +| `get-started/before-you-install` | Have a server, a domain and HTTPS before installing | +| `get-started/install` | Run an instance with Dokploy, Coolify or Docker Compose, and claim the operator role | +| `get-started/connect-a-bank` | Configure a Powens sandbox and link a test bank | +| `use/budget` | Read spending, fix categories, plan a budget | +| `use/bank-connections` | Add, sync and remove banks, and act on their errors | +| `use/assistant` | Ask questions, and know where the data goes | +| `use/account` | First steps, sign-in, two-factor, passkeys, sessions, language | +| `run/email` | Turn on email delivery | +| `run/sign-in` | Choose sign-in options, and know their limits | +| `run/updates-and-backups` | Update, back up, restore | +| `run/troubleshooting` | Match a symptom to its fix | +| `reference/configuration` | Look up any variable (tables generated by `env:sync`) | +| `reference/bank-providers` | Powens and Enable Banking: credentials, limits, switching | +| `reference/reverse-proxy` | Put Freenary behind a proxy, one hostname or two | +| `reference/security` | Judge the security model and what data leaves the instance | +| `reference/scaling` | Size, replicate, and know what grows | +| `reference/from-source` | Build the images | +| `reference/api` | Call the API: paths, session cookie, errors, limits | +| `reference/procedures` | What each procedure returns and refuses | +| `reference/glossary` | Look up a word the app uses | +| `contribute` | Run the code locally and open a pull request | + +## Anchors other code links to + +These headings must keep their slug: `reference/bank-providers#powens`, `#enable-banking`; `run/email#resend`, `#smtp`; `reference/configuration#a-value-in-the-environment-wins`; `run/troubleshooting#the-server-does-not-restart-after-setup` (from `apps/web/src/pages/setup/model/labels.ts`). + +## Which page owns each fact + +| Fact | Owner | +| --- | --- | +| Prerequisites, domain, HTTPS options | `before-you-install` | +| Powens redirect URI must be HTTPS | `bank-providers` | +| Powens sandbox walkthrough: test bank, password | `connect-a-bank` | +| What a new `BETTER_AUTH_SECRET` breaks, sealing | `security` | +| Session length, password length, one-time codes | `run/sign-in` | +| Leaked-password check: setting | `run/sign-in`; symptom: `run/troubleshooting` | +| 90-day sync window, sync triggers | `use/bank-connections` | +| Currency display | `use/budget` | +| Language and theme | `use/account` | +| `/api-reference` is a live API, bank tokens in the database | `security` | +| Rate limits of procedures, 9-second forwarding limit | `api` | +| Access level of each procedure | `procedures` | +| Switching bank provider | `bank-providers` | +| Assistant model configuration | `configuration#assistant` | + +## Deliberately excluded + +- Internals: tables, pipeline stages, encryption parameters beyond what an operator decides. They stay in `docs/engineering/`. +- Planned features. The sidebar shows Portfolio, Analysis and Goals with a Planned badge; the docs say what exists. +- A generated procedure reference. It needs `.output()` and `.errors()` in `packages/api`, a code change. +- The frozen `0.1` folder. + +## Questions for a maintainer + +1. Dokploy labels (Advanced → Base64 import, HTTPS toggle) and the Coolify domain syntax: not checkable from the repo. +2. Vendor facts: Powens refuses `http://localhost`, the test connector password, sandbox limits, Enable Banking showing the key once. +3. Internal transfers count in totals today. Intended? +4. Assistant tool totals are always labelled `EUR` (`assistant/tools.ts`). +5. Powens ignores the end of a sync window; Enable Banking sends both ends. +6. `BETTER_AUTH_SECRETS` is read by better-auth and declared nowhere. +7. `instance.check` can't return `NOT_IMPLEMENTED`; the code path is dead. +8. `bun install` needs Node 20.19+, 22.12+ or 24+ for Prisma's preinstall; nothing declares it. +9. Two unit tests fail at HEAD (`deriveMerchantKey` card markers, `categoriseTransaction` direction invariant). +10. Resolved: `next` now links the `dev` branch (`gitConfig.branch` in `apps/fumadocs/src/shared/config/site.ts`), and the snapshot pins it to the release tag. The Dokploy tab names branch `dev`; the snapshot pins it to `gitConfig.releaseBranch` (`main`). +11. Untranslated strings reach French readers: `Other Income` in the Cash Flow chart, raw server errors in three toasts. diff --git a/docs/audit/docs-review.md b/docs/audit/docs-review.md new file mode 100644 index 0000000..d890da8 --- /dev/null +++ b/docs/audit/docs-review.md @@ -0,0 +1,69 @@ +# Documentation review + +Review of the full rewrite of `apps/fumadocs/content/docs/next` (2026-10-17 and 18). [`docs-plan.md`](docs-plan.md) holds the scope and [`docs-validation.md`](docs-validation.md) what was run. + +## How the pages were written + +1. Six read-only inventories listed every fact for one area, each with a `path:line` or the command that showed it. They are in [`evidence/`](evidence). +2. The sitemap, the reader goal of each page, the anchors other code links to, and which page owns each fact were fixed before any writing. +3. Writers worked from the inventories and the code only. A first round edited the old pages instead of replacing them. Those 12 pages were emptied and written again by writers forbidden to read any old page, any frozen version or any earlier audit. +4. Each page was compared sentence by sentence with its previous version: + +| Share of sentences kept from the old page | Pages | +| --- | --- | +| 0% | `contribute`, `reference/from-source`, `reference/scaling`, `reference/security`, `run/email`, `run/updates-and-backups` | +| 1–10% | `index`, `reference/configuration`, `reference/glossary`, `reference/procedures`, `reference/reverse-proxy`, `run/sign-in`, `use/budget` | +| 11–33% | `get-started/*`, `reference/api`, `reference/bank-providers`, `run/troubleshooting`, `use/account`, `use/assistant`, `use/bank-connections` | + +The kept sentences are mostly quoted UI labels, error strings and one-line facts that have one correct wording. Total length fell from about 26,000 to 21,900 words; `reference/procedures` fell from 6,455 to 3,655. + +## What the evidence changed + +Claims the old pages got wrong, or left out, that the rewrite states from code: + +- Custom categories apply to budget lines only; a transaction takes a built-in category. +- Resetting a category undoes the choice for every transaction of that company. +- No screen changes a password. `Forgot password?` exists only with email configured. +- Tax residency is fixed after onboarding and decides only the bank list. +- With no email provider, a Google, Apple or single sign-on sign-in is refused for an address that already has a password account. The page quotes the message and gives the two ways out. +- `instance.check` never returns `NOT_IMPLEMENTED`; a disabled integration answers `verified`. +- `instance.save` always answers `restartRequired: true`; `instance.complete` decides. +- Powens receives only the start of a sync window; Enable Banking receives both ends. +- A server image starts the API server; Compose reuses it for `migrate`. +- `bun install` needs Node 20.19+, 22.12+ or 24+ for Prisma's install check. +- `/rpc` and `/api-reference` reach the same handlers; blocking one path hides only the reference page and the OpenAPI document. +- An unknown procedure answers `Not Found` as text, not the JSON error body. + +Two interface strings made false claims and were corrected in `en.json` and `fr.json`: `assistant_model_none_chosen` and `onboarding_country_description`. + +## Independent review + +Three review rounds checked all 22 pages against the code, with no access to the writers' reports. Round 1 ran on a different model (GPT-5.6); its re-check stalled twice, so rounds 2 and 3 ran on the repository's read-only review agent. + +Round 1 returned 10 findings, all fixed: + +1. `index` named two places data leaves the instance; it now names all six and links Security. +2. The Coolify template URL on `main` returned `404`. `next` now links the `dev` branch, through `gitConfig.branch` in `apps/fumadocs/src/shared/config/site.ts`, and the snapshot pins it to the release tag. The rule documents changed with it. +3. The live restore ran while the server accepted writes. It now stops `web` and `server` first. +4. Every page had the same `` and no meta description. The docs route now emits both from the page frontmatter. +5. `index` implied custom categories go on transactions. +6. Running without Docker skipped creating the PostgreSQL role and database. +7. A repository anchor existed only on `dev` (fixed by 2). +8. `procedures` said a procedure with no **Errors** line could fail only on access or schema. +9. `bank-providers` repeated sync facts owned by `use/bank-connections`. +10. `from-source` said the server image runs migrations. + +Round 2 confirmed those 10 fixes and returned 15 more, all fixed. The most important: + +- `run/email` promised a code on address change; the code configures no address change. +- `glossary` defined `Reconnect to resume importing`; nothing ever sets the status that shows it. +- `run/sign-in` repeated the rate-limit table owned by `api`, with one wrong row. +- The Dokploy step deployed branch `main`, which has no `deploy/` yet. It now names `dev`, and the snapshot pins it to `main`. +- Five facts sat on two pages; each now lives on its owner and is linked from the others. +- `security` ended with a checklist that repeated the page. + +Round 3 returned six findings, five in this audit trail and one link anchor, all fixed. The final verdict is in `docs-validation.md`. + +## Needs maintainer review + +The plan's "Questions for a maintainer" holds the detail: panel labels, vendor facts, internal transfers in totals, `EUR` on assistant totals, `BETTER_AUTH_SECRETS`, the dead `NOT_IMPLEMENTED` path, the undeclared Node requirement, two failing unit tests, and untranslated strings. diff --git a/docs/audit/docs-validation.md b/docs/audit/docs-validation.md new file mode 100644 index 0000000..9f44831 --- /dev/null +++ b/docs/audit/docs-validation.md @@ -0,0 +1,59 @@ +# Documentation validation + +What was run for the rewrite, and what wasn't. "Ran" means executed; "reasoned" means not. + +## Gates (ran, final state) + +| Command | Result | +| --- | --- | +| `cd apps/fumadocs && bun scripts/check-docs.ts --strict` | 60 pages, 11 meta files, 0 errors, 0 warnings | +| `cd apps/fumadocs && bun run build` | Exit 0 | +| `cd apps/fumadocs && bun run types:check` | Exit 0 | +| `bunx --bun oxlint`, `bun x ultracite check` on changed code | Exit 0 | +| `bun run env:check` | 48 declared variables, both artifacts current | + +The build prerenders no `next` page, so `vite preview` served the built site and every `next` page was requested: 22 of 22 answered `200` as HTML and as `.md`, each with its own `<title>` and meta description. `vite dev` failed on every route of the site with `Vite environment "ssr" is unavailable`, an environment fault unrelated to the pages. + +Every repository link in `next` (5 unique URLs) answers `200`, and its one heading anchor exists on `dev`. `docs:check` checks internal links and anchors; it fetches no external link. + +## Commands from the pages (ran) + +Against throwaway containers, removed afterwards: + +- The local contributor path in `contribute`, on a scratch worktree: install, `.env` files, PostgreSQL, migrations, `bun run dev`, sign-up. +- Backup with `pg_dump -Fc`; restore over a live database with `--clean --if-exists --no-owner`; restore into an empty database on a fresh volume; restore under a different `BETTER_AUTH_SECRET`. +- Every startup failure in `run/troubleshooting`: missing or short secret, missing `.env` with variables exported, invalid URL, changed `POSTGRES_PASSWORD`, half-set email variables, unreadable instance configuration. +- Every path, method, status and error body in `reference/api`, against a running API server. + +The live restore was later changed to stop `web` and `server` first; that exact sequence was not run again. + +## Reader journeys (ran) + +A separate agent used only the built `next` pages, Docker, and `FREENARY_VERSION=dev` in place of `latest`. The `dev` images were built from the same commit as this branch's base. + +| Journey | Outcome | Changed afterwards | +| --- | --- | --- | +| **Newcomer:** localhost try-out, operator account, skip setup | Worked: start order, sign-in screen, `isOperator: true`, setup completed | The try-out broke on non-default ports; the page now says which variables to set, including `POSTGRES_PORT`. `api` gained the sign-up body. `procedures` explains `restartRequired` and how to skip setup by API | +| **Integrator:** cookie, `auth.viewer`, `budget.getAccounts`, `settings.listCategories` on both paths, OpenAPI document, error cases | Every documented output, cookie attribute and error matched | Unknown-procedure `404` body now documented | +| **Troubleshooter:** six startup failures, 502, backup, both restores, wrong secret | Every real output matched its troubleshooting entry | "The server log stays silent" narrowed to the browser's own calls | + +An AI agent followed the steps; that shows they run as written, not that a person understands them. A human Getting Started run from a clean machine is still required before release. + +## Independent review (ran) + +| Round | Reviewer | Findings | Result | +| ----- | ------------------------- | -------- | --------- | +| 1 | GPT-5.6, separate session | 10 | All fixed | +| 2 | Repository review agent | 15 | All fixed | +| 3 | Same agent, re-check | 6 | All fixed | +| 4 | Same agent, re-check | 0 | Approved | + +Round 1's re-check stalled twice on the GPT-5.6 provider, so later rounds used the repository's read-only review agent. + +## Not validated + +- Dokploy and Coolify tabs. The labels come from vendor documentation, not a panel. +- Linking a real Powens or Enable Banking bank (needs vendor credentials and an HTTPS domain). +- Email through Resend or SMTP; Google, Apple and single sign-on; passkeys in a browser; an outside assistant model. +- The hosting-panel `docker exec` restore variants. +- `/llms.txt` lists the newest release only, by design, so it carries no `next` page. diff --git a/docs/audit/evidence/EvApi.md b/docs/audit/evidence/EvApi.md new file mode 100644 index 0000000..31b5b5b --- /dev/null +++ b/docs/audit/evidence/EvApi.md @@ -0,0 +1,74 @@ +I audited the HTTP API against the code and against a live server. The existing `reference/api.mdx` and `reference/procedures.mdx` hold up almost entirely. I found one wrong claim, one dead error code, and a few gaps. + +**How I checked it:** I read `apps/server/src/index.ts`, all of `packages/api/src/routers/**`, the `packages/auth` cookie and limit settings, the `/ai/chat` handler, and better-auth 1.7.1's rate-limiter code. Then I ran the server against a throwaway Postgres 17 (all migrations applied), with `NODE_ENV=development`, `BETTER_AUTH_URL=http://localhost:39123` and `CORS_ORIGIN=http://localhost:39124`. A second run added a dummy AI model so `/ai/chat` got past its 503. The container and temp files are removed; I didn't edit any repo file. + +## Procedure count: 42 + +assistant 3, auth 3, bankConnection 6, budget 14, instance 5, onboarding 2, settings 7, plus `healthCheck` and `privateData`. The served `spec.json` lists the same 42 paths, all `post`. + +**Access:** + +- **Public (5):** `healthCheck`, `auth.accountExists`, `auth.capabilities`, `auth.viewer`, `instance.status`. +- **Operator (4):** `instance.describe`, `instance.check`, `instance.save`, `instance.complete`. An operator procedure is one where the database `role` is `OPERATOR`. +- **Protected:** the other 33. + +The docs' table matches this. + +## Recorded responses + +| Call | Status | Body | +| --- | --- | --- | +| `POST /rpc/healthCheck` `{}` | 200 | `{"json":"OK"}` | +| `POST /api-reference/healthCheck` | 200 | `"OK"` | +| `POST /rpc/auth/viewer` (no cookie) | 200 | `{"json":{"kind":"guest","sessionCookieShared":true}}` | +| `auth.viewer` (signed in) | 200 | `{"json":{"kind":"member","onboarded":false,"sessionCookieShared":true}}` | +| `POST /rpc/budget/getAccounts` (no cookie) | 401 | `{"json":{"defined":false,"code":"UNAUTHORIZED","status":401,"message":"Unauthorized"}}` | +| `instance.describe` as the second account | 403 | `…"code":"FORBIDDEN"…"message":"Forbidden"` | +| `auth.accountExists` `{"email":"nope"}` | 400 | `"message":"Input validation failed","data":{"issues":[{…"path":["email"],"message":"Invalid email address"}]}` | +| `onboarding.completeOnboarding` `["fr"]` | 400 | issue `Expected an ISO 3166-1 alpha-2 country code` | +| `budget.syncAccounts` body `null` | 400 | `Invalid input: expected object, received null` | +| `auth.accountExists` ×22 | 20 × 200, then 429 | `TOO_MANY_REQUESTS` | +| `/ai/chat` with no cookie / signed in with no model / bad body | 401 / 503 / 400 | `unauthorized` / `unconfigured` / `bad_request` | +| `/ai/chat` burst after 1 `saveTurn` + 3 bad requests | 26 × 400, then 429 | The bucket is shared and malformed requests count | + +Every documented domain error I reached came back with the exact code and message the docs give: `Unknown category: bogus`, `Category not found`, `Transaction not found`, `Bank connection not found`, `No country to list banks for`, the 503 `Could not load banks for this country`, `Missing bank connection state`, `Unknown banking provider`, `Invalid bank connection state`, `Conversation is not the active one`, `Unknown integration`, and `outcome: missing-fields`. + +## Confirmed live + +- **Routes and methods:** + - `/rpc` accepts PUT and DELETE (200); only GET gets 405 `METHOD_NOT_SUPPORTED`. + - `/api-reference/<proc>` accepts POST only; GET and PUT get 404 with `Not Found`. + - A PUT under `/api/auth/*` gets 405 with `Method Not Allowed`. + - An unknown path gets 404 with `NOT_FOUND`. + - OPTIONS gets 204 on any path. +- **OpenAPI document:** `3.1.1`, title `API Reference`, version `0.0.0`, and `servers[0]` is `http://localhost:39123/api-reference`. It has no `components` and no `security`, every response schema is `{anyOf:[{},{not:{}}]}`, and it omits `/ai/chat`. +- **Session cookie:** `better-auth.session_token`, with `Max-Age=604800; Path=/; HttpOnly; SameSite=Lax`. + - The `__Secure-` prefix applies when `BETTER_AUTH_URL` starts with `https://` (from the better-auth source, not seen live). + - `rememberMe:false` drops `Max-Age` and adds a `better-auth.dont_remember` cookie. +- **Bearer token:** `Authorization: Bearer <session token>` alone gets 401. +- **CORS:** a foreign `Origin` gets no `Access-Control-Allow-Origin` header. + +## Docs that need a fix + +1. **`NOT_IMPLEMENTED` (501) can't happen.** `packages/api/src/instance/probes.ts` has a check for all six integration:variant pairs, and an unknown variant fails earlier with 404. Both pages list it (the `instance.check` errors and the API error table). Live, `instance.check` on `email`/`disabled` returns `{"outcome":"verified"}`, which means `verified` also covers "nothing to check" for a `disabled` variant. That isn't documented. +2. **`budget.getTransactions` `limit` isn't an integer.** The schema is `z.number().min(1).max(100)` with no `.int()`, and the spec shows `limit` without `"type":"integer"`. `limit: 1.5` returned 200 on an empty account. With rows, Prisma would get `take: 2.5`, which should be a 500. I didn't reproduce that because the account had no transactions. Either add `.int()` (a code fix) or document the rule. +3. **`budget.getMerchants` `search` uses ILIKE without escaping.** In `budget.ts`, `%` and `_` act as wildcards. `getTransactions` uses Prisma `contains`, so the two `search` fields behave differently. Neither page says so. +4. **The procedure limiter ignores test mode.** `auth.accountExists` and `saveTurn`/`/ai/chat` count even under `NODE_ENV=test`, while better-auth turns its own limiter off there. Worth a line for anyone writing tests. +5. **When the caller address is unknown,** `consumeRateLimit` puts the caller in one shared `untrusted` bucket, and better-auth uses a single shared `no-trusted-ip` bucket per path. Every unplaced caller then shares one limit. The docs only say the address comes from `X-Forwarded-For`. + +## Smaller gaps + +- **CORS responses echo the request's header names in `Access-Control-Expose-Headers`** (for example `host,user-agent,accept,origin,…`). This is the `@elysiajs/cors` default; it's harmless and undocumented. +- **`bankConnection.disconnect` on a connection whose stored `provider` isn't registered** gets a 500, because `getProvider` throws with no handler. +- **`exchangeCode` doesn't check the provider is configured** before using it. +- **`privateData` returns the full user record,** including `twoFactorEnabled`, `taxCountries` and `emailVerified`. On `/rpc` its dates also come with an oRPC `meta` array. The page's "raw Better Auth user record" is accurate. +- **`instance.save` with a `disabled` variant answers `saved`, not `nothing-to-save`,** and clears every stored key of that integration (`writesOf` writes `null` for each one). The docs match the code, but this effect is easy to miss. + +## Checked against code, no change needed + +- **Better-auth rate limits:** + - The built-in defaults are 3/10 s for `/sign-in*`, `/sign-up*`, `/change-password` and `/change-email`, and 3/60 s for the password-reset and one-time-code paths. + - The two-factor plugin sets `/two-factor/*` to 3/10 s. + - Freenary's own rules override those: 10/60 s for email and social sign-in, 5/300 s for sign-up, 10/300 s for TOTP, 5/300 s for backup codes, 20/60 s for the two passkey paths. Everything else is 100/60 s, and counters live in the database. + - The API page's table matches this, and the "other 40 procedures have no limit" line is right. +- **`/ai/chat` refusal order:** 401, then 503, then 429 (charged before the body is read), then 400. The locale regex is `^[a-z]{2}(?:-[A-Z]{2})?$`, questions are capped at 8000 characters, there are at most 6 steps, and the 7 tools call `budget.*` through an in-process client. diff --git a/docs/audit/evidence/EvContrib.md b/docs/audit/evidence/EvContrib.md new file mode 100644 index 0000000..05deff8 --- /dev/null +++ b/docs/audit/evidence/EvContrib.md @@ -0,0 +1,84 @@ +I ran the local non-Docker path on a scratch worktree and it works, with one catch: `bun install` fails under the Node version on this machine. The contributing docs never mention Node. I also found a few wrong statements in the docs and two failing tests. + +## Bugs and mismatches + +1. **Node is a hidden requirement.** A clean `bun install` fails under Node 23.11: `Prisma only supports Node.js versions 20.19+, 22.12+, 24.0+` (the `prisma` preinstall exits 1). It worked under Node 26.8. `contribute.mdx` says you only need "Bun `1.3.14` … and Docker". It also mixes up two Bun versions: the scratch run used Bun 1.4.2 and CI uses `bun-version: latest`, while `packageManager` pins `bun@1.3.14`. +2. **Which branch a release is cut from.** `.github/CONTRIBUTING.md:54` says "`dev` is the branch a release is cut from". That's wrong: `release.yml` refuses anything but `main` (the "Refuse anything but main" step). The release paragraph at line 78 is correct. +3. **`DOCS_PORT` does nothing.** `docker-compose.dev.yml:139` sets `DOCS_PORT: ${DOCS_PORT:-4000}`, but nothing reads it. The port comes from `vite dev --port=4000` in `apps/fumadocs/package.json`, and that overrides `port: 3000` in `apps/fumadocs/vite.config.ts`. `scripts/env-sync.ts:268` allow-lists it. +4. **Wrong command in the mail compose comment.** `docker-compose.mail.yml` says "`bun run dev:up --mail` layers this file". The script is `bun run dev:mail`. The comment's form also happens to work, because `dev.ts` filters `--mail` out of any position. +5. **Two tests fail at HEAD (b7baccd).** A full `bun test` gave 646 pass, 2 fail: + - `deriveMerchantKey > card path > drops deferred and immediate debit markers` + - `categoriseTransaction > direction invariant > keeps that same merchant on a debit` + +## Confirmed by running it + +On a scratch worktree at HEAD (`/tmp/freenary-contrib-scratch`, since removed). Postgres ran under a separate project name, `COMPOSE_PROJECT_NAME=freenary-contrib-scratch`, because `docker-compose.yml` uses the fixed name `freenary`; that kept the shared volumes untouched. + +```bash +bun install # fails on Node 23, works on Node 26 +cp .env.example .env +echo 'DATABASE_URL=postgresql://postgres:password@localhost:5432/freenary' > apps/server/.env +docker compose up -d --wait postgres # healthy +(cd packages/db && bun run db:migrate:deploy) # "All migrations have been successfully applied." +bun run dev +``` + +- **Ports:** server on 3000 (`/api/auth/ok` returns `{"ok":true}`), web on 3001 (307), docs on 4000 (200). +- **Sign-up:** `POST /api/auth/sign-up/email` returned 200 with a session cookie and token, so you're signed in straight away with no email provider. +- **Teardown:** containers, volume and network removed, worktree removed and pruned. + +I did not run `dev:up` (Traefik). Your own `freenary-dev` stack and `devproxy-freenary-traefik-1` were already running, so I checked that path by reading the code only. + +## Facts for the docs + +**Root scripts:** + +- `dev`, `build`, `check-types`, `docs:check`, `check:fsd` (runs `lint:fsd`) go through turbo. +- `env:sync` / `env:check` run `scripts/env-sync.ts`. +- `dev:up` = `dev.ts up --build --watch`; `dev:mail` = `dev.ts --mail up --build --watch`; also `dev:down`, `dev:logs`, `dev:reset` (`down -v`, then `up --build --watch`). +- `docker:up`/`docker:down`/`docker:logs`, `db:*`, `build:data`, `deploy:dokploy`, `check` (`ultracite check`), `fix`. +- There is no `test` script. + +**Workspace scripts:** + +- **`check-types`:** only `apps/server` (`tsc -b`) and `packages/ui` have one. +- **`apps/web`:** no type-check script; `dev` runs a Paraglide compile, then `vite dev`. +- **`apps/fumadocs`:** `dev` (port 4000), `build`, `types:check`, `docs:check`, `docs:snapshot`, `lint:fsd`. +- **`packages/db`:** `postinstall` runs `prisma generate`, plus `db:migrate:deploy`. + +**turbo.json:** + +- `build` depends on `^build` and writes `dist/**`. +- `docs:check` and `lint:fsd` declare their own inputs. +- `dev`, `db:*` and `build:data` are uncached. + +**lefthook pre-commit** (runs jobs in parallel): + +- `oxlint --fix`, then stage the fixes. +- `oxfmt --write`, then stage the fixes. +- `env:sync`, but only when a file matches `packages/env/src/schema*.ts`, `docker-compose*.yml` or `scripts/env-sync.ts`. + +The hook is installed by the `lefthook` package's own postinstall; there is no `prepare` script. + +**CI (`ci.yml`):** runs on PRs, on pushes to `dev`/`main`, and on `workflow_dispatch`. + +- The `ci` job runs `bun install --frozen-lockfile`, then these steps in order: **Lint & format**, **Type check**, **Architecture**, **Documentation**, **Environment**, **Build**. +- The `compose` job runs `config --quiet` on the dev, dev+mail and proxy files, on `docker-compose.yml`, on `deploy/docker-compose.yml` and on Coolify (with `exclude_from_hc` stripped). It also asserts that the production files fail when required values are empty. + +**`scripts/dev.ts` and `dev-identity.ts`:** + +- **Slug:** taken from `FREENARY_SLUG` (shell first, then the worktree's `.env`), else the branch (a detached HEAD doesn't count), else the directory name, else `dev`. It is lowercased, with every run of other characters turned into `-`, and capped at 40 characters. +- **Names it derives:** Compose project `freenary-<slug>`; hosts `{web,server,docs,mail}.<slug>.freenary.localhost` over plain HTTP; cookie domain `.<slug>.freenary.localhost`, which `AUTH_COOKIE_DOMAIN` can override. +- **Traefik:** only `up`/`start`/`restart`/`run`/`reset` touch it. It creates the `devproxy` network (subnet `172.30.0.0/16`, without the subnet if that fails). It reuses any running container that publishes port 80 on `devproxy`. If something else holds port 80, it errors and suggests `docker network connect`. Otherwise it starts `docker-compose.proxy.yml` (`traefik:v3.7`, project `devproxy-freenary`). +- **Dev stack:** services `postgres` (`postgres:18-alpine`, not on `devproxy`), a one-shot `bootstrap` (`prisma migrate deploy`), `server` (alias `freenary-server`, port 3000), `web` (3001), `docs` (4000). It publishes no host ports and has watch sync plus a rebuild on `bun.lock`. +- **Mail file:** adds `axllent/mailpit:v1.28` (alias `freenary-mailpit`, UI on 8025), and sets `EMAIL_PROVIDER=smtp`, `SMTP_HOST=freenary-mailpit`, `SMTP_PORT=1025`, `SMTP_SECURE=false` and `EMAIL_FROM` on the server. + +**Tests:** 62 `*.test.ts` files, under `apps/web/src`, `packages/{api,auth,email,instance-config,ui}` and `scripts/`. Run them with `bun test` or `bun test <path>`. CI runs none. + +**Branches and releases:** + +- PRs target `dev`. +- Pushes to `dev`/`main` publish images tagged `<branch>` and `sha-<short>`. +- `Release` is a `workflow_dispatch` with a `version` input, run from `main` only. It checks the version format and refuses an existing tag. For a stable version it requires `apps/fumadocs/content/docs/X.Y`; a pre-release skips that check. It then tags, builds images through `images.yml` and creates the GitHub release (`--prerelease` or `--latest`). + +I changed no repository files. diff --git a/docs/audit/evidence/EvInstall.md b/docs/audit/evidence/EvInstall.md new file mode 100644 index 0000000..ade2436 --- /dev/null +++ b/docs/audit/evidence/EvInstall.md @@ -0,0 +1,145 @@ +I've traced every item in the assignment through the code and checked both compose files with `docker compose config`. I edited no files. Below are the facts, with seven discrepancies at the end that the docs should account for. + +## Product features + +`apps/web/src/app/shell/nav-items.ts` lists six areas: + +| Item | Status | What is there | +| --- | --- | --- | +| **Home** (`/`) | Shipped | The assistant chat (`pages/home/ui/assistant-*`). It needs `AI_BASE_URL` and `AI_MODEL`; with either unset, the route answers `503 unconfigured`. | +| **Portfolio** | Planned | Sidebar shows a **Planned** badge and dims the item; the page is a 10-line stub. | +| **Budget** | Shipped | Sub-pages **Transactions** and **Recurring**, with KPI strips, many charts and filters. | +| **Analysis** | Planned | **Planned** badge. | +| **Goals** | Planned | **Planned** badge. | +| **Settings** | Shipped | Four groups: **Appearance**, **Budget**, **Connections**, **Security**. Security covers passkeys, 2FA, sessions and linked accounts. | + +Other routes: `/login`, `/setup`, `/onboarding`, `/callback/$provider`, and the dev-only `dev-avatar`. + +## Compose files + +**Root `docker-compose.yml`** + +- Publishes server on `${SERVER_PORT:-3000}`, web on `${WEB_PORT:-3001}`, and Postgres on `127.0.0.1:${POSTGRES_PORT:-5432}` only. +- Requires `env_file: .env`. Defaults: `BETTER_AUTH_URL` = `http://localhost:3000`, `CORS_ORIGIN` = `http://localhost:3001`, `NODE_ENV=production`. +- Web gets `SERVER_URL=http://server:3000`, and `PUBLIC_SERVER_URL` takes `BETTER_AUTH_URL`. + +**`deploy/docker-compose.yml`** + +- Publishes no host port; web is reachable only on port 3001 (`expose`) inside the Docker network. +- `.env` is optional (`required: false`). +- Takes one public origin, `FREENARY_URL`, and uses it for `BETTER_AUTH_URL`, `CORS_ORIGIN` and `PUBLIC_SERVER_URL`. + +**`deploy/coolify.yml`** + +- Uses Coolify magic variables: `SERVICE_PASSWORD_POSTGRES`, `SERVICE_HEX_64_AUTHSECRET`, and `SERVICE_URL_WEB` (from `SERVICE_URL_WEB_3001`). +- `migrate` has `exclude_from_hc: true`. + +**`deploy/dokploy/template.toml`** + +- Generates `FREENARY_URL=http://${domain}`, `FREENARY_VERSION=latest`, a 32-character Postgres password and a 64-character auth secret. +- Maps the domain to the `web` service on port 3001. + +**`scripts/dokploy-import.ts`** (`bun run deploy:dokploy`) prints base64 of the JSON `{compose, config}` built from `deploy/docker-compose.yml` and `template.toml`. + +**Root scripts:** `docker:up` / `docker:down` / `docker:logs`, `db:start` / `db:watch` / `db:stop` / `db:down` (postgres via the root compose), `dev:up` / `dev:mail` / `dev:down` / `dev:logs` / `dev:reset`, and `deploy:dokploy`. + +## Required-variable messages + +Confirmed with `docker compose config`: + +- **Root file:** `Set POSTGRES_PASSWORD in .env` and `Set BETTER_AUTH_SECRET in .env`. With both set, the check passes. +- **Deploy file:** + - `Set POSTGRES_PASSWORD` + - `Set BETTER_AUTH_SECRET to 32 characters or more` + - `Set FREENARY_URL to the public URL of the web app, such as https://freenary.example.com` + - With all three set, the check passes. + +Compose prints each message once per place the variable is used, so the same line repeats. + +## Images and tags (`images.yml`) + +- **Triggers:** a push to `dev` or `main`, plus a manual run that takes a `ref`. +- **Builds:** `server` and `web` for `linux/amd64` (on `ubuntu-latest`) and `linux/arm64` (on `ubuntu-24.04-arm`), merged into one multi-arch manifest. +- **Tags:** + - A stable `vX.Y.Z` ref gets `X.Y.Z`, `X.Y`, `X` and `latest`. + - A pre-release (version contains `-`) gets only its exact version. + - A branch gets `<branch>` and `sha-<7 characters>`. +- The `FREENARY_VERSION` build argument carries the version. + +## First start order + +1. `postgres` becomes healthy (`pg_isready`, every 10s, 5 retries). +2. `migrate` runs `bun x prisma migrate deploy` and must exit successfully. It re-runs on every `up`. +3. `server` starts once `postgres` is healthy and `migrate` has completed. Its healthcheck fetches `:3000/` (start period 10s). +4. `web` starts once `server` is healthy; its healthcheck fetches `:3001/`. + +## Sign-up and operator claim + +- Sign-in is one screen: **Welcome to Freenary**, "Enter your email to sign in or create an account". The form then shows either sign-in or sign-up depending on whether the address has an account. +- Sign-up submits with **Create account**. Passwords are 12–128 characters. +- Email verification is required only when an email provider is configured. In that case the user enters a 6-digit code (valid 600s), and verifying signs them in. +- **Operator claim** happens in a better-auth hook that runs after a session is created (`packages/auth/src/index.ts`), not at sign-up. `claimForFirstSession` sets `instanceSetup.claimedAt` once on the `singleton` row and gives that user `role: OPERATOR`. So the first account to get a session becomes operator, whatever sign-in method it used. A claim failure is only logged as a warning. + +## `/setup` flow + +**Who sees it.** `/setup` sends guests to `/login`. Until setup is complete, `/login`, every signed-in page and `/onboarding` redirect **every** member to `/setup`, not only the operator. A non-operator sees **Only the operator can set up this instance** and no stepper. + +**Steps.** The stepper reads **Bank**, **Email**, **Done**. + +**Bank step** — titled **Bank provider**, "Pick the service that connects bank accounts." + +- Options: **Powens**, **Enable Banking**, and **Skip for now**, which is always last. +- Powens fields: **Powens domain**, **Client ID**, **Client secret**. Enable Banking fields: **Application ID**, **Private key (PEM)**. + +**Email step** — titled **Email delivery**. + +- Options: **Resend**, **SMTP**, **Skip for now**. +- Resend fields: **Sender address**, **API key**. +- SMTP fields: **Sender address** and **Host**; under **More options**: **Port**, **Implicit TLS**, **User**, **Password**. + +**Moving through a step.** + +- **Continue** opens the credentials form, titled **Connect {provider}** with a link "How to get {provider} credentials". +- **Save and continue** tests the connection first and saves only if the test passes. +- If the test fails, **Save anyway** appears. Saving that way shows the toast "Saved without a connection test." +- If a provider is set in the environment, the step instead shows **{provider} is ready** / "Set by your server's environment variables.", with **Test connection** and **Continue** buttons. + +**Done step** — titled **Review and finish**. + +- Each integration shows **Configured**, **Off** or **Incomplete**. +- **Finish setup** sets `completedAt`. If any saved change has not been applied yet, the server exits after 250ms (`process.exit(0)`) and the page shows **Applying your settings…**. It polls every 2s for up to 60s. +- If the server does not come back, the page shows **The server did not restart** with **Try again** and **View troubleshooting**. +- On success, the toast is "The instance is set up." and the user lands on `/`. + +## Powens sandbox link flow + +- **Domain.** `POWENS_DOMAIN` takes a value like `acme-sandbox`; `.biapi.pro` is added if missing. `BANKING_PROVIDER` defaults to `powens`. +- **Setup check.** It calls `POST /auth/init` to create a throwaway user, then deletes it with `DELETE /users/me`. If the delete fails, it logs a warning to delete the user in the Powens console. +- **Connect.** + 1. The app creates a Powens user per Freenary user (`/auth/init`). + 2. It gets a single-access code from `/auth/token/code?type=singleAccess`. + 3. It redirects to `https://webview.powens.com/connect` with `client_id`, `code`, `connector_uuids`, `domain`, `redirect_uri` and `state`. +- **Redirect URI.** It is `${CORS_ORIGIN}/callback/powens`. The Powens console very likely has to allow this URI, but that comes from Powens, not from this repo — check it before putting it in the docs. +- **Callback.** `/callback/powens` runs only in the browser and needs a session. It shows "Finishing bank connection", then one of: + - **Bank connected** + - "Bank connection cancelled. Nothing was linked." (when Powens returns `error`) + - "The bank sent back an incomplete response." (when `state` is missing) + - "Bank connection failed. You can try again or skip." (from onboarding) or "…You can try again." (from settings) +- **Unusable connection.** If the connection needs the user's action, the error is `Powens connection needs the user's attention (<state>)`. + +## Onboarding + +1. **Tax residency** ("What's your tax residency?"): pick countries with the **Add a country…** combobox. Only FR is fully supported; other countries get a **Limited support** badge. The step also shows "Want to add support for your country?" with a **Contribute here** link, then **Continue**. +2. **Bank connection** ("Connect your bank") appears only if the bank provider `isConfigured()`. Otherwise **Continue** finishes onboarding straight away. The step has **Back**, **Skip for now** and **Finish** / **Finish ({count})**. + +The chosen countries are kept in `sessionStorage` (`freenary:onboarding`) across the bank redirect. Completing shows "You're all set!" and goes to `/`. + +## Discrepancies to flag + +1. The root compose requires `.env` (`env_file: .env`), while the deploy compose treats it as optional. +2. The root compose uses two origins (3000 and 3001); the deploy, Coolify and Dokploy setups use one origin through `web`. +3. `coolify.yml` sets no `restart:` policy, but **Finish setup** depends on the container restarting after `process.exit(0)`. Whether Coolify adds a restart policy itself is unverified. +4. The Dokploy template sets `FREENARY_URL` to `http://`, not `https://`. +5. In onboarding, **Skip for now** and **Finish** call the same handler. +6. The operator is the first account to get a session, not strictly the first sign-up. With email verification on, that means the first account to verify. +7. All members are blocked at `/setup` until the operator finishes it. diff --git a/docs/audit/evidence/EvOperate-full.md b/docs/audit/evidence/EvOperate-full.md new file mode 100644 index 0000000..eada284 --- /dev/null +++ b/docs/audit/evidence/EvOperate-full.md @@ -0,0 +1,41 @@ +# Run-an-instance audit (2026-10-17) + +## Verified facts (source) + +- Email: EMAIL_PROVIDER resend|smtp; resend needs RESEND_API_KEY+EMAIL_FROM; smtp needs EMAIL_FROM+SMTP_HOST; SMTP_PORT default 465 if SMTP_SECURE else 587; SMTP_PASSWORD only used when SMTP_USER set (packages/email/src/registry.ts). Setup-screen probe: resend GET https://api.resend.com/domains (needs full-access key); smtp transport.verify(); 10 s timeout (instance-config/src/probe.ts). +- Subjects (auth/src/emails.ts): "Confirm your Freenary email address", "Reset your Freenary password", "Confirm your new Freenary email address", "Your Freenary verification code". Plain text. +- OTP: 6 digits, 600 s, 3 attempts, stored hashed; sign-in OTP disabled (/sign-in/email-otp 404, type sign-in -> INVALID_OTP_TYPE). better-auth emailOTP default send limit 3/60 s. +- Email on => requireEmailVerification, sendOnSignIn, autoSignInAfterVerification. +- Passwords 12-128. Sessions: expiresIn 7 d, updateAge unset => better-auth default 1 d (1440*60), freshAge 7 d. Re-auth window 15 min on /link-social, /unlink-account, passkey register/delete (SESSION_NOT_FRESH). +- Trusted device 30 d. Passkey rpID = hostname(CORS_ORIGIN), origin = CORS_ORIGIN. +- Rate limits (storage database, off when NODE_ENV=test): default 100/60 s; sign-in/email 10/60; sign-in/social 10/60; sign-up/email 5/300; passkey auth 20/60; 2FA totp 10/300; backup code 5/300; plus ACCOUNT_EXISTS 20/60, AI chat 30/300. +- TRUSTED_PROXIES -> advanced.ipAddress.trustedProxies; IPv4-mapped IPv6 rejected with "write an IPv4-mapped address in its IPv4 form, e.g. 10.0.0.0/24". +- Providers: Google/Apple need ID+SECRET; OIDC needs DISCOVERY_URL+CLIENT_ID, secret optional, scopes openid,email,profile + OIDC_SCOPES, requireIdTokenVerification. Apple adds trusted origin https://appleid.apple.com. +- Callbacks (better-auth 1.7.1, basePath /api/auth): /api/auth/callback/{google,apple,oidc}. Generic OAuth now uses core /callback/:id (generic-oauth/index.mjs:66), NOT /oauth2/callback. +- Linking: enabled, allowDifferentEmails false, allowUnlinkingAll false; implicit link needs provider emailVerified AND local user emailVerified (requireLocalEmailVerified default true, link-account.mjs:83). +- Leaked password: haveIBeenPwned plugin when AUTH_PASSWORD_BREACH_CHECK (default true). Errors "Failed to check password. Please try again later." / "Failed to check password. Status: <n>". +- Setup finish: process.exit(0) after 250 ms when settings changed; relies on restart: unless-stopped. + +## Executed (throwaway project audit-backup, postgres:18, 12 migrations) + +Backup (works): docker compose exec -T postgres pg_dump -U postgres -d freenary -Fc > freenary.dump Restore in place (works, rc 0): docker compose exec -T postgres pg_restore -U postgres -d freenary --clean --if-exists --no-owner < freenary.dump Restore into empty DB on fresh volume (works, rc 0): docker compose exec -T postgres pg_restore -U postgres -d freenary --no-owner < freenary.dump Plain SQL variant (works, citext recreated): pg_dump ... > freenary.sql ; psql -U postgres -d freenary -v ON_ERROR_STOP=1 -q < freenary.sql Not executed: docs' exact dropdb --force / createdb sequence (equivalent to the fresh-DB case). + +## Observed failures (exact) + +- Compose, empty .env: "required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env" (also BETTER_AUTH_SECRET). +- Compose, no .env file at all (vars exported): "env file <dir>/.env not found: stat ...: no such file or directory" — root compose env_file is required. +- POSTGRES_PASSWORD changed after first init: migrate "Error: P1000: Authentication failed against database server..."; up prints 'service "migrate" didn't complete successfully: exit 1'. +- Unreachable DB in migrate: "Please make sure your database server is running at `host:port`." +- Bad env (image): "❌ Invalid environment variables: [... path: [ "TRUSTED_PROXIES", 0 ], message: "Invalid input" ...]", "error: Invalid environment variables", container "Restarting (1)". Short secret: "Too small: expected string to have >=32 characters". Bad bool: 'Invalid option: expected one of "true"|"1"|...'. +- Half email env (image): "error: EMAIL_PROVIDER=resend requires RESEND_API_KEY to be set." restart loop. +- Production boot with DB readable by migrate? no — server boot DB unreadable: "error: The instance configuration could not be read: PrismaClientKnownRequestError ..." (dev only warns "... The environment alone is in force."). +- Secret rotated: "WARN (#1): The stored value of EMAIL_PROVIDER does not open with the current BETTER_AUTH_SECRET." value dropped, server boots. +- Healthy boot: "Server is running on <BETTER_AUTH_URL>", "This instance has no operator yet. The first account to sign in at <CORS_ORIGIN> becomes the operator." +- Web proxy: 502 body "Bad Gateway", log "The API server at <target> did not answer: <reason>", timeout 9 s; 499 when caller left. +- Stored config invalid: setup shows "The stored configuration did not load: {detail}" (InstanceSettingsRejected "The instance configuration does not validate: ..."). + +## Doc gaps found + +1. run/sign-in: linking needs local address confirmed; on instance without email, local emailVerified stays false, so Google/Apple/OIDC sign-in with a password account's address fails (account not linked). Not stated. +2. run/troubleshooting "won't start" table lacks "The instance configuration could not be read" (prod server cannot reach DB at boot) and the no-.env-file compose error. +3. Everything else checked in run/{email,sign-in,updates-and-backups,troubleshooting} matched code/run. diff --git a/docs/audit/evidence/EvOperate.md b/docs/audit/evidence/EvOperate.md new file mode 100644 index 0000000..f76a923 --- /dev/null +++ b/docs/audit/evidence/EvOperate.md @@ -0,0 +1,36 @@ +The audit is done, and the backup round trip works. I tore down the throwaway stack and its volume, and changed no repo files. The full report is in `local://run-instance-audit.md`. + +**Backup and restore.** I ran these against a throwaway `postgres:18` from the root compose file, with all 12 migrations applied: + +- **Backup:** `docker compose exec -T postgres pg_dump -U postgres -d freenary -Fc > freenary.dump` +- **Restore over the live database:** `pg_restore -U postgres -d freenary --clean --if-exists --no-owner < freenary.dump` exited 0, and the test row came back. +- **Restore into an empty database on a fresh volume:** `pg_restore --no-owner` exited 0. +- **Plain SQL:** a `pg_dump` text dump loaded back with `psql -v ON_ERROR_STOP=1` also worked, `citext` extension included. +- **Not run:** the docs' exact `dropdb --force` then `createdb` sequence. It should behave like the fresh-database case. + +**Failures I reproduced, with their exact messages:** + +- **Empty `.env`:** compose stops with `required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env`, and the same for `BETTER_AUTH_SECRET`. +- **No `.env` file at all**, even with the variables exported: `env file …/.env not found`. The root compose file requires the file to exist. +- **`POSTGRES_PASSWORD` changed after the first start:** migrate fails with `Error: P1000: Authentication failed…`, then `service "migrate" didn't complete successfully: exit 1`. +- **Bad environment in the server image:** the log shows `❌ Invalid environment variables` with `path`/`message` entries, and the container loops on `Restarting (1)`. +- **Half-set email variables:** the server loops on `error: EMAIL_PROVIDER=resend requires RESEND_API_KEY to be set.` +- **Database unreadable when the server starts in production:** it dies with `The instance configuration could not be read: …`. In development it only warns. +- **Different `BETTER_AUTH_SECRET`:** the log shows `WARN … The stored value of EMAIL_PROVIDER does not open with the current BETTER_AUTH_SECRET.` The stored value is dropped and the server still starts. + +**Checked against the code and better-auth 1.7.1** (it all matches the `run/` pages): + +- One-time codes: 6 digits, 10 minutes, 3 attempts, at most 3 sends a minute. +- Sessions: 7 days. `updateAge` isn't set, so better-auth's default of 1 day applies, which makes the docs' "6 to 7 days" correct. +- Passwords: 12 to 128 characters. +- The rate-limit table and the 15-minute re-authentication window. +- Passkeys are tied to the hostname of `CORS_ORIGIN`. +- The leaked-password errors, both `Failed to check password…` variants. +- The email subjects. + +**Provider callbacks:** they are `/api/auth/callback/{google,apple,oidc}`. In better-auth 1.7.1, generic OAuth goes through the core `/callback/:id` route, not `/oauth2/callback`, so the docs are right. + +**Gaps in the docs:** + +1. **Provider sign-in on an instance without email.** better-auth only joins a provider sign-in to an existing account when that account's address has been confirmed. Without email, password accounts are never confirmed, so a Google, Apple or SSO sign-in with the same address is refused ("account not linked"). `run/sign-in.mdx` says "same, confirmed email address" but doesn't spell out this consequence. +2. **Missing troubleshooting rows.** The "Freenary won't start" table in `run/troubleshooting.mdx` has no row for `The instance configuration could not be read` or for the missing `.env` file error. diff --git a/docs/audit/evidence/EvPlatform.md b/docs/audit/evidence/EvPlatform.md new file mode 100644 index 0000000..ff70eab --- /dev/null +++ b/docs/audit/evidence/EvPlatform.md @@ -0,0 +1,230 @@ +I've finished the audit of this area. Every fact below comes from code or the installed dependency. I edited nothing. `bun scripts/env-sync.ts --check` passes: "48 declared variables, both artifacts current". + +## Things that need a decision or a fix + +1. **`VITE_SERVER_URL` can't be relative.** `apps/web/src/shared/config/server-url.ts` has a code path for a relative value like `/api`. The schema in `schema-web.ts` uses `z.url()`, which rejects a relative value, so that path likely never runs. Either the schema or the code is wrong. +2. **Assistant totals are always labelled `currency: "EUR"`.** `packages/api/src/assistant/tools.ts` hard-codes it in `search_transactions`, even for accounts in another currency. +3. **Powens ignores the end of the sync window.** It filters on `last_update=<dateFrom> 00:00:00` and never uses `dateTo`. Enable Banking sends both `date_from` and `date_to`. If a docs page says "transactions between X and Y" for both providers, it is wrong for Powens. +4. **The categorisation preamble in `.env.example` says no amount is sent.** That's true for the exact amount, but a coarse `amountBucket` is sent (see Classifier). The counterparty name, which can be a person's name, is sent too. +5. **The assistant and the classifier read `env` directly.** The setup screen can't configure them, and only the environment does. Any page that implies the setup screen covers them is wrong. + +## Generated configuration tables + +- `scripts/env-sync.ts` writes two files: the root `.env.example` and the tables in `reference/configuration.mdx`. +- Each table sits between `{/* env-sync:start <id> */}` and `{/* env-sync:end */}`. The 14 ids, in page order: `count`, `core`, `origins`, `database`, `email`, `sign-in`, `bank`, `assistant`, `categorisation`, `raw`, `client`, `compose`, `compose-dev`, `inert`. +- **Schema tables** have four columns: Variable | Default | What it does | Absent or wrong. + - Default comes from `meta.default`, else the Zod default, else "unset". + - A variable marked `productionRequired` reads "`<value>` outside production, required in production". +- **Other tables:** + - `raw`: Variable | Read by | What it does. + - `client`: Variable | Default | What it does. + - `compose`: four columns. + - `compose-dev`: three columns. + - `inert`: Variable | Why it does nothing. +- **Variables the generator declares itself** (the `EXTERNAL` list): + - compose: `POSTGRES_PASSWORD`, `FREENARY_VERSION`, `FREENARY_URL`, `SERVER_PORT`, `WEB_PORT`, `POSTGRES_PORT`. + - compose-dev: `FREENARY_SLUG`, `SERVER_HOST`, `WEB_HOST`, `DOCS_HOST`, `MAIL_HOST`, `DOCS_PORT`. + - raw: `DEV_PUBLIC_URL`, `PUBLIC_SERVER_URL`, `SERVER_URL`, `SKIP_ENV_VALIDATION`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`. + - inert: `POSTGRES_DB`, `POSTGRES_USER`. +- **Placement in `.env.example`** (`envFile`): + - Only `BETTER_AUTH_SECRET` and `POSTGRES_PASSWORD` are written uncommented. + - `NODE_ENV` and `PORT` are left out. + - Everything else is written as a commented example. + - The file ends with "Images and host ports" and "Development only" blocks. +- **What the generator refuses:** + - A schema key with no `.describe()`, `onError` or `example`. + - A `process.env` or `Bun.env` read, or a compose `${VAR}`, that nothing declares. Names starting with `VERCEL_` are allowed. + - An `EXTERNAL` entry that no file uses, unless it is inert. +- **Validation** (`packages/env/src/server.ts`): + - `emptyStringAsUndefined: true`, so an empty value counts as unset. + - Any non-empty `SKIP_ENV_VALIDATION` turns validation off. + - `devDefault` gives `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN` and `DATABASE_URL` defaults only outside production. + +## Setup screen vs environment (`packages/instance-config`) + +- **Which keys the setup screen can set:** 14 (`instanceConfigurableKeys`). + - Bank: `BANKING_PROVIDER`, `POWENS_DOMAIN`, `POWENS_CLIENT_ID`, `POWENS_CLIENT_SECRET`, `ENABLE_BANKING_APP_ID`, `ENABLE_BANKING_PRIVATE_KEY`. + - Email: `EMAIL_PROVIDER`, `EMAIL_FROM`, `RESEND_API_KEY`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`. +- **Precedence is per key.** A non-empty `process.env[key]` always wins (`environmentOwns`). Otherwise the stored value is laid over the environment and the whole result is validated again. +- **If the combined values fail validation:** the error is kept as `instanceConfigFault` (shown by `instance.describe`), and the instance runs on the environment alone. +- **If the stored settings can't be read at boot:** + - Production: it throws, so the server doesn't start. + - Otherwise: it warns "… The environment alone is in force." +- **If one stored row won't decrypt:** it logs "The stored value of X does not open with the current BETTER_AUTH_SECRET." and skips that row. +- **Settings are read once, at boot.** + - `save` returns `restartRequired: true`, but doesn't restart. + - `complete` exits the process 250 ms later (exit code 0), and only if a change is still unapplied. The restart then depends on `restart: unless-stopped`. +- **Refusals from `save`:** + - `environment-owned`: the environment picks a different provider. + - `missing-fields`. + - `invalid`. + - `nothing-to-save`: the environment already owns every field. +- **What the screen shows:** + - Secret values: never. + - Text and URL values that come from the environment: masked to the first 4 and last 4 characters with "…" between. A value of 8 characters or fewer shows only "…". +- **Credential check (probe):** times out after 10 s. It reports `unreachable`, `rejected` or `invalid`. +- **Operator role:** + - Granted by the session-create hook through `claimForFirstSession`. The first session wins, using the single `instance_setup` row (id `singleton`) as a lock. + - Nothing grants the role later. + - It covers only `instance.check`, `instance.complete`, `instance.describe` and `instance.save`. `instance.status` is public. + +## Sealing (`seal.ts`) + +- Algorithm: AES-256-GCM. +- Key: HKDF-SHA256 from `BETTER_AUTH_SECRET`, with salt and info both `freenary/instance-setting/v1`, 32 bytes. +- 12-byte random IV. The row key is bound as additional authenticated data, so a value copied to another row won't open. +- Stored format: `iv.tag.body`, each part base64url. +- Table `instance_setting` holds `key`, `sealed`, `updatedAt` and `updatedBy` (the operator's id). +- A new `BETTER_AUTH_SECRET` makes every sealed row unreadable. The same secret also: + - signs sessions; + - encrypts the two-factor secret and backup codes (better-auth); + - is the HMAC key for the bank connection state. + +## Bank providers + +**Common to both** + +- The default provider is `powens`. Only new connections use `BANKING_PROVIDER`. +- Each connection stores its own `provider`, and syncs and disconnects use that one. +- Callback URL: `${CORS_ORIGIN}${callbackPath}`, which is `/callback/powens` or `/callback/enable-banking`. It is the web origin, not the API origin. +- **Connection state:** + - Plain JSON: `institution {country, id, name}`, `original`, `providerId`, `returnTo` (`onboarding` or `settings`), plus `hmac`. + - `hmac` is HMAC-SHA256 of `userId:payload` with `BETTER_AUTH_SECRET`, compared in constant time. + - It is signed, not encrypted. +- **Sync:** + - No scheduler and no timers. A sync only starts when a user calls `budget.syncAccounts` or `budget.recategorise`. + - Window: `PROVIDER_SERVED_HISTORY_DAYS = 90`. It starts at `lastSyncedAt`, or 90 days back on the first sync or when `force` is set. + - Connections run one at a time, accounts one at a time, and transactions are upserted one by one. + - Only connections with status `ACTIVE` are synced. A failure is recorded as an error string and doesn't change the status. + +**Powens** + +- Needs all three of domain, client id and client secret. Host: `<domain>.biapi.pro/2.0`; a `.biapi.pro` suffix is stripped. +- `/auth/init` creates a per-user token. It is stored as plain text in `bank_provider_user.accessToken`, with a unique (user, provider) pair. +- Webview: `https://webview.powens.com/connect` with a `singleAccess` code. +- **States that block a sync:** `SCARequired`, `actionNeeded`, `additionalInformationNeeded`, `decoupled`, `passwordExpired`, `webauthRequired` and `wrongpass`. The error reads "Powens connection needs the user's attention (state)". +- **Bank list:** a bank is listed only if it isn't `hidden` or `restricted` and offers the `bank` or `wealth` product. +- **Data:** investment holdings are fetched for investment accounts. Pages hold 1000 transactions. Currency falls back to EUR, and precision falls back to 2 decimals. +- **When the last connection is removed:** `DELETE /users/me` is called, and its failure is ignored. The token row is then deleted. + +**Enable Banking** + +- Needs both the app id and the private key. +- Every request carries a new RS256 JWT: `kid` and `sub` are the app id, `iss` is `enablebanking.com`, `aud` is `api.enablebanking.com`, and it lasts 3600 s. +- A literal `\n` in the key becomes a real newline. +- API host: `https://api.enablebanking.com`. +- Consent: `valid_until` is now + 90 days, with `psu_type` `personal`. Nothing tracks a shorter grant from the bank. +- The first page requests `strategy=longest`. Later pages follow `continuation_key`. +- No per-user token and no holdings. + +**Switching provider** + +- Existing connections keep the old provider. +- If the old provider's credentials are removed: + - Their sync fails with "Powens is not configured" or "Enable Banking is not configured". + - `disconnect` skips the revocation (`revocationRequested: false`) and still deletes the row. +- Only `getProviderAvailability` and the bank list follow the new default. + +## Merchant dictionary + +- File: `packages/api/data/merchants.jsonl.gz`. +- Built into the image by `build:data`. That script downloads the newest `data-*` release of `GITHUB_REPOSITORY` and falls back to `build:data:generate`. +- Loaded lazily and limited to the countries in use. The file is missing → it warns `Dictionary file not found` and skips. +- With `DICTIONARY_PUBLIC_KEY` set, it needs `merchants.jsonl.gz.sig` and checks it with Ed25519 `crypto.verify`. Otherwise it refuses with "Dictionary signature missing:" or "Dictionary signature invalid:". +- Key generation: `cd packages/api && bun run generate:key`. + +## Classifier + +- Reads `env` only. Requests time out after 10 s. `Authorization: Bearer` is sent only when a key is set. +- **Slots:** + - A slot needs both its URL and its model, or startup fails. + - A fallback without a primary fails. + - The same protocol and model in both slots fails. + - The cache key is a SHA-256 of provider, model, taxonomy version, merchant key or descriptor, country, direction and MCC. +- **What leaves the instance (`ClassificationInput`):** + - `amountBucket`: `micro` under 1000 minor units, `small` under 5000, `medium` under 20000, otherwise `large`. + - `channel`, `counterpartyName`, `country`, `currency`, `direction`, `merchantCategoryCode`, `merchantKey` (blank on the IBAN path), `normalisedDescriptor`, `path`. + - No exact amount, no date, no IBAN, no account. +- **How each protocol sends it:** + - `system-one`: `{model, questions, state: input}`, posted to the URL as given. + - `llm`: the whole input as JSON in the user message, posted to `/chat/completions` with `logprobs: true`. + - `zero-shot`: `{inputs, parameters}`. `inputs` joins descriptor, counterparty, MCC, channel, bucket, currency and country. + +## Assistant + +- Uses `createOpenAICompatible` with `AI_BASE_URL`, `AI_MODEL` and `AI_API_KEY`, from `env` only. Missing either the base URL or the model → `503 unconfigured`. +- **Limits:** + - Rate: 30 questions per 300 s per user (`ai-chat:<userId>`). Over the limit → `429 rate_limited`. + - Questions are capped at 8000 characters and 6 steps. +- **Sent to the model:** + - A system prompt with the locale, today's date and the date range of the data. + - Tool results: + - Accounts: id, bank name and account name, no IBAN. + - `search_transactions`: up to 20 rows (10 by default), each with amount, date, description, counterparty, category and id. + - The other tools send aggregates. + +## Reverse proxy + +- **Forwarded paths** (`FORWARDED_PATH_PREFIXES`): `/api/auth`, `/rpc`, `/ai/chat`, `/api-reference`. A path matches on the exact prefix or the prefix followed by `/`. +- Forwarding happens only when `SERVER_URL` is set. +- **Headers:** + - Dropped: `connection`, `host`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`. + - `accept-encoding` is forced to `identity`. + - `content-encoding` and `content-length` are stripped from the response. + - Redirects are passed back as they are (`redirect: manual`). +- **Timeout:** 9 s, then `502 Bad Gateway`. A caller that leaves gets a 499. +- `X-Forwarded-For` passes through unchanged, and rate limits read it through `TRUSTED_PROXIES`. +- **Cookies** (`resolveCookiePolicy`): + - Always `httpOnly`. + - `Secure` when `BETTER_AUTH_URL` uses https. + - `SameSite=Lax` when `AUTH_COOKIE_DOMAIN` is set or both hostnames are the same, otherwise `None`. + - Two hostnames over plain HTTP with no domain throws in production and warns otherwise. + - Bad `AUTH_COOKIE_DOMAIN` values (trailing dot, single label, not a parent of both hostnames) throw. +- **Cookie names:** better-auth 1.7.1 adds `__Secure-` over https. The names are `better-auth.session_token` (7 days), `session_data` and `dont_remember`. + +## Security surface + +- **Ports:** + - `docker-compose.yml` publishes `server` on `${SERVER_PORT:-3000}` and `web` on `${WEB_PORT:-3001}`, on every interface. + - Postgres is published on `127.0.0.1` only. + - `deploy/docker-compose.yml` publishes no host ports (`expose` only). +- **Sessions:** last 7 days, and `freshAge` equals the session lifetime. +- **Re-authentication within 900 s** for: `/link-social`, passkey add and delete, `/unlink-account`. +- **Two-factor:** + - Trusted devices last 30 days. + - Lockout uses the library defaults (not set here): 10 failures lock it for 900 s, stored in `two_factor.failedVerificationCount` and `lockedUntil`. +- **Breach check:** enabled by default. It sends the first 5 SHA-1 hex characters to `api.pwnedpasswords.com/range/` with `Add-Padding`, on sign-up, change password and reset password. It fails closed: if the service can't be reached → 500 "Failed to check password…". +- **Passwords:** 12 to 128 characters. +- **One-time codes:** 6 digits, valid 600 s, 3 attempts, stored hashed. `/sign-in/email-otp` and the `sign-in` code type are turned off. +- **Rate limits:** stored in the database, 100 per 60 s by default. Per-path rules are in `policy.ts`. They're off when `NODE_ENV=test`. +- **CORS:** only `CORS_ORIGIN`, with credentials, methods GET, POST and OPTIONS. + +## Scaling + +- **One sync per user:** + - `claimSyncRun` claims the `sync_run` row (keyed by userId) when it is `FINISHED` or silent for more than 5 minutes (`SYNC_RUN_ABANDONED_AFTER_MS`), or creates it with `skipDuplicates`. + - The claim is keyed by `startedAt`, so writes from a stale run do nothing. + - Progress is written at most once a second. +- **Database connections:** two Prisma clients per API process (`@freenary/db` and the one `createAuth` makes). Each uses `PrismaPg` with no pool size set, so the `pg` default applies. +- **Volumes:** only `freenary_postgres_data`. The dictionary lives in the image. +- **Logs:** the file log is written outside production only. + +## Building images + +- **Server image:** + - Builder: `oven/bun:1`, with `SKIP_ENV_VALIDATION=1` and a placeholder `DATABASE_URL` set only in the builder stage. + - Build argument: `GITHUB_REPOSITORY` (default `suiramdev/freenary`). + - BuildKit secret `GITHUB_TOKEN`, which doesn't end up in the image. + - Runs `bun dist/index.mjs` and exposes port 3000. +- **Web image:** + - Build argument `FREENARY_VERSION` becomes `VITE_FREENARY_VERSION`. + - Runner sets `HOST=0.0.0.0` and `PORT=3001`, and runs `.output/server/index.mjs`. +- **`.dockerignore`** (the only one, at the root) excludes: + - `node_modules`, `.git`, build outputs and logs; + - every Dockerfile and `docker-compose*.yml`; + - every `.env` and `.env.*` except `.env.example`. +- **Platforms:** CI (`images.yml`) builds `linux/amd64` on `ubuntu-latest` and `linux/arm64` on `ubuntu-24.04-arm`, pushes by digest, and passes `GITHUB_REPOSITORY`, `FREENARY_VERSION` and the `GITHUB_TOKEN` secret. + +## Docs I spot-checked + +These match the code: `scaling.mdx` (two pools, `sync_run`, restart behaviour), `security.mdx` (10 failures / 15 min lockout, fail-closed breach check, AES-GCM sealing), `reverse-proxy.mdx` (9 s limit, `/api/auth/ok` returning `{"ok":true}`), and `bank-providers.mdx` (90-day consent and window). Point 3 above may still affect `bank-providers.mdx`. diff --git a/docs/audit/evidence/EvUse.md b/docs/audit/evidence/EvUse.md new file mode 100644 index 0000000..8e3749c --- /dev/null +++ b/docs/audit/evidence/EvUse.md @@ -0,0 +1,116 @@ +I checked every signed-in screen against the routers, the sync code and both message catalogs. Catalog parity is clean, but I found several behaviour gaps the docs either miss or get wrong. I edited nothing; this is a report only. + +## Message catalogs + +- All 845 message keys used in `apps/web/src` exist in both `en.json` and `fr.json`, and every catalog key is used in the code. The scanner's only "missing" hit was `m.id` in a test file, a false positive. +- **English shown to French readers:** the server sends a fixed label `"Other Income"` (`packages/api/src/routers/budget.ts:123`), and the Cash Flow chart shows it as-is (`cash-flow-sankey.ts:154`). +- **Untranslated server errors in toasts:** these use the raw `error.message`: + - creating or editing a custom category, e.g. `"A category with that name already exists"` and the nesting refusals (`use-custom-category-form.ts:70`) + - disconnecting a bank (`use-bank-connections.ts`) + - saving the budget profile, which only uses the translated fallback when the message is empty (`use-budget-profile-editor.ts:180`). +- **Same English word, two French words:** "Money left" is `budget_money_left` = "Reste à vivre" on the budget chart, but `settings_budget_money_left` = "Reste à répartir" in settings. + +## Screens and what backs them + +- **Navigation** (`nav-items.ts`): `Home`, `Budget` (with `Transactions` and `Recurring` under it) and `Settings` work. `Portfolio`, `Analysis` and `Goals` are marked `Planned` and show "{page} is planned". `/budget` redirects to `/budget/transactions`. +- **Route gate:** a guest goes to `/login`, an instance that is not set up goes to `/setup`, and a user who has not finished onboarding goes to `/onboarding`. +- **Onboarding:** two steps, "Tax residency" then "Bank connection". You must pick 1 to 10 countries. "Skip for now" finishes onboarding without a bank. Your choices are saved in the browser until you finish. + - **There is no screen to change tax residency afterwards.** The bank list always uses it. +- **First steps checklist** (sidebar): `Connect your bank`, `Set up your budget`, `Protect your account`. + - "Protect your account" counts as done with two-factor or at least one passkey. + - The panel shows while any step is open. When the last step is done, it holds for 1.4 s, then closes. It comes back if a step becomes undone, e.g. you disconnect your only bank. +- **Settings sections:** `appearance` (the default), `connections`, `budget`, `security`. + - Theme is stored by `next-themes` in the browser's localStorage. + - Language comes from the `PARAGLIDE_LOCALE` cookie, then the browser's preferred language, then English. +- **Budget plan:** at most 200 lines, each label up to 60 characters, amounts are whole cents of 0 or more. Saving replaces every line at once. +- **Custom categories:** + - Names are 1 to 40 characters and must be unique ignoring case. + - Nesting goes one level deep. + - Deleting one moves its lines to the fallback category for its group. + - Custom categories can only be used on budget lines; transactions accept only built-in categories (`budget.ts` `updateTransactionCategory`). +- **Changing a transaction's category:** the choice is saved for that merchant, applies to other transactions from the same merchant that were not fixed by hand, and applies to future ones. + - **Reset also undoes the choice for the other transactions from that merchant** and deletes the saved merchant choice. The docs only say reset "puts back the category Freenary chose". +- **Account security:** sessions last 7 days. + - Adding or deleting a passkey, and linking or unlinking a provider, need a sign-in less than 15 minutes old. Otherwise the user sees "Sign in again before changing how your account is accessed…". Renaming a passkey is not on that list. + - "Remember this device" lasts 30 days. + - One-time email codes: 6 digits, valid 10 minutes, 3 tries. + - Passwords: 12 to 128 characters. + - **Settings has no way to change or set a password.** The `Password` row only shows the date it was added; the only route is `Forgot password?`, which needs email configured. + +## Sync + +- **What starts a sync:** + - Opening Budget runs a quiet sync once per page load, only if accounts exist and no sync is running. It reads from the last sync date. Failures show a toast; success shows nothing. + - `Sync transactions` re-reads 90 days for every connection, then clears the automatic categories and sorts again. Hand-picked categories are kept. + - The re-sync button on a bank's row does the same for that one connection. +- **No scheduled or server-side sync exists.** +- **One sync at a time per user.** A run left silent for 5 minutes counts as abandoned and a new one can start. The app checks progress every 1.5 s and refreshes the data every 100 imported transactions. +- **Bug: a bank row can say "{institution} re-synced" when nothing ran.** If another sync is already running, the server returns `started:false, success:true`, and the row's re-sync button still shows the success toast (`use-bank-connections.ts`). +- **Bug: transactions can be skipped.** If fetching one account fails, the connection's last-sync date still moves forward (`bank-sync.ts`, where `importAccountsInSeries` catches the error before `lastSyncedAt` is updated). + - For Enable Banking, which does not re-fetch the account list, this happens every time an account fails. + - The next quiet sync starts from the new date, so the failed days are skipped until a full 90-day re-sync. + - It also contradicts the docs' claim that the `Synced` date "stops moving" (`use/bank-connections.mdx:80`). +- **"Reconnect to resume importing" can never appear.** It needs a connection status other than `ACTIVE`, but nothing ever sets `EXPIRED` or `ERROR`. + +## Internal transfers + +- **How they are detected:** after every sync, the same amount going out of one account and into another, in the same currency, within 24 hours. The user needs at least 2 accounts, and hand-fixed transactions are ignored. +- **Where they are left out:** + - automatic categorisation (so they show as "Other") + - clearing categories on a re-sync + - Recurring detection + - the Recurring income figure. +- **Where they are still counted:** + - the transaction list and its totals + - Cash Flow + - Spending Breakdown + - Budget vs. actual + - Fixed vs. variable. + + This matches `use/budget.mdx:80`. + +## Currency + +- Each transaction row and its details show the transaction's own currency. +- On Transactions, the tab totals, figures, charts, amount filters and budget-plan amounts are all formatted as EUR (`transaction-list.tsx:112-116`, and `formatCurrency` defaults to EUR). +- Recurring uses the currency most of your accounts use (EUR if none, ties go to the alphabetically first code), and each row keeps its own currency. +- Amounts are never converted between currencies. + +## Rate limits users can hit + +| Action | Limit | What the user sees | +| --- | --- | --- | +| Email sign-in, social sign-in | 10 / 60 s | `auth_error_rate_limited` "Too many attempts. Wait a moment and try again." | +| Sign-up | 5 / 300 s | same | +| Authenticator code | 10 / 300 s | same | +| Recovery code | 5 / 300 s | same | +| Passkey sign-in steps | 20 / 60 s | same | +| Any other auth path | 100 / 60 s | same, in login and security settings | +| Email address check | 20 / 60 s | `auth_email_check_blocked_rate_limited` | +| Assistant questions and saved answers | 30 / 300 s, per user, shared by both model paths | `assistant_error_rate_limited` "Too many questions in a row. Wait a moment and try again." | + +- These limits come from `packages/auth/src/policy.ts`. +- **A question over 8,000 characters is not explained.** It is refused as `bad_request`, which shows the generic "The assistant could not answer. Try again." +- An answer stops after 6 steps. + +## Recurring rules (`recurrence.ts`) + +- **Time span:** the last 12 months, out-going payments only, grouped by merchant. +- **How often a payment must repeat:** + - weekly: every 5 to 9 days, at least 4 times + - monthly: every 25 to 35 days, at least 3 times + - quarterly: every 80 to 100 days, at least 2 times + - yearly: every 340 to 395 days, at least 2 times + - irregular: every 10 to 400 days, at least 4 times. +- **Labels:** + - "Commitments" needs regular timing (variation within 20%) and a steady amount (within 25%). + - "Confirmed" also needs the amount within 5% and at least 3 payments. + - Everything else is "Spending patterns". + +## What to fix first + +1. Stop moving the last-sync date when an account fails, or correct `use/bank-connections.mdx:80`. +2. Don't show the per-bank "re-synced" toast when `started` is false. +3. Translate `"Other Income"`, and map the custom-category, disconnect and budget-save errors to message keys. +4. Either delete the unreachable "reconnect" row state or make the sync set `EXPIRED`. +5. Document that reset also reverts the other transactions from that merchant, and that Settings has no password change. diff --git a/docs/engineering/bank-provider.md b/docs/engineering/bank-provider.md index bda5a59..4ab7ee6 100644 --- a/docs/engineering/bank-provider.md +++ b/docs/engineering/bank-provider.md @@ -278,12 +278,12 @@ For anything above a mapper, replace `globalThis.fetch` with a stub that answers 3. **Idempotence.** Run the sync twice against the same stub. The row counts must not change. This test catches an unstable transaction id. ```bash -bun run db:start -cd packages/db && bun run db:migrate:deploy +docker compose up -d --wait postgres +(cd packages/db && bun run db:migrate:deploy) bun test packages/api/src/providers ``` -[Local development stack](./local-stack.md) covers both database paths. CI runs no tests, so run each file by hand. +The migration reads `DATABASE_URL` from `apps/server/.env`; [Local development stack](./local-stack.md) shows how to write it, and covers both database paths. CI runs no tests, so run each file by hand. ### What a stub cannot prove diff --git a/docs/engineering/data-model.md b/docs/engineering/data-model.md index 273ba9c..7735842 100644 --- a/docs/engineering/data-model.md +++ b/docs/engineering/data-model.md @@ -352,7 +352,7 @@ The schema edit and its migration land in one commit. Never edit a migration tha A new model, column or enum value belongs in the tables above. Other docs quote these names verbatim, so a rename with no documentation edit makes them silently wrong. -`bun run db:push` is opt-in development tooling for a scratch database, and its CLI is gated behind `FREENARY_DB_PUSH=1`. It writes no history and it would drift a migrated database. +`bun run db:push` is development tooling for a scratch database. It writes no history and it would drift a migrated database. The operator side of a migration is [Update an instance](../../apps/fumadocs/content/docs/next/run/updates-and-backups.mdx). diff --git a/docs/engineering/local-stack.md b/docs/engineering/local-stack.md index 1585da0..9dfa4aa 100644 --- a/docs/engineering/local-stack.md +++ b/docs/engineering/local-stack.md @@ -6,8 +6,8 @@ Two paths run Freenary on your machine. The containerised path runs every part i | Path | Start it with | It needs | It gives you | | --- | --- | --- | --- | -| Containerised | `bun run dev:up` | Docker and a free port `80` | Five services, one hostname per application, one isolated stack per worktree | -| Local | `bun run db:start`, then `bun run dev` | Docker and two variables in two files | Three host processes and PostgreSQL on `127.0.0.1:5432` | +| Containerised | `bun run dev:up` | Docker, and port `80` free or held by the shared dev proxy | Five services, one hostname per application, one isolated stack per worktree | +| Local | PostgreSQL, `db:migrate:deploy`, then `bun run dev` | Docker and two variables in two files | Three host processes and PostgreSQL on `127.0.0.1:5432` | ## The containerised path @@ -128,21 +128,26 @@ The paraglide ignore prevents one exact failure. Both sides compile that directo ## The local path ```bash -bun run db:start -bun run db:generate +cp .env.example .env +echo 'DATABASE_URL=postgresql://postgres:password@localhost:5432/freenary' > apps/server/.env +docker compose up -d --wait postgres +(cd packages/db && bun run db:migrate:deploy) bun run dev ``` +The API server does not migrate at boot, so the schema step is not optional: without it the server exits at startup, because it reads the `instance_setup` table. `bun install` already generated the Prisma client. + | Command | What it does | | --- | --- | -| `bun run db:start` | `docker compose up -d postgres` against `docker-compose.yml`. It publishes `127.0.0.1:5432` and names the database `freenary` | +| `docker compose up -d --wait postgres` | Starts PostgreSQL from `docker-compose.yml` and waits for its `pg_isready` health check. It publishes `127.0.0.1:5432` and names the database `freenary`. `bun run db:start` is the same call without `--wait` | +| `(cd packages/db && bun run db:migrate:deploy)` | `prisma migrate deploy`. The script lives in `packages/db` alone. It applies every migration to the empty database | | `bun run dev` | `turbo run dev` for the three applications | | `bun run dev:web`, `bun run dev:server` | The same task for one application | | `bun run db:watch`, `bun run db:stop`, `bun run db:down` | Follow, stop and remove the same PostgreSQL container | -> [!WARNING] **`docker-compose.yml` needs two variables, not a file.** It writes `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET` as `${VAR:?…}`, which fails on an unset or empty value from any source. A root `.env` file is the usual source, and a shell export works the same. The CI `compose` job proves the rule. It parses the file with an emptied `.env`, and it fails when the parse succeeds. +> [!WARNING] **`docker-compose.yml` needs two variables, not a file.** It writes `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET` as `${VAR:?…}`, which fails on an unset or empty value from any source. A shell export satisfies that check, but the `server` service also reads `env_file: .env`, so the file must exist. The CI `compose` job proves the rule. It parses the file with an emptied `.env`, and it fails when the parse succeeds. -The Prisma CLI reads its own file. `packages/db/prisma.config.ts` loads `apps/server/.env` with dotenv, then hands `process.env.DATABASE_URL || undefined` to the datasource. It sets no default on purpose, so `db:generate` works in a checkout with no database. The commands that connect — `db:push`, `db:migrate` and `db:studio` — need `DATABASE_URL` in `apps/server/.env`: +The Prisma CLI reads its own file. `packages/db/prisma.config.ts` loads `apps/server/.env` with dotenv, then hands `process.env.DATABASE_URL || undefined` to the datasource. It sets no default on purpose, so `db:generate` works in a checkout with no database. The commands that connect — `db:migrate:deploy`, `db:migrate`, `db:push` and `db:studio` — need `DATABASE_URL` in `apps/server/.env`, which the second line above writes: ```dotenv DATABASE_URL=postgresql://postgres:password@localhost:5432/freenary @@ -190,7 +195,7 @@ On the local path PostgreSQL answers on `127.0.0.1:5432`, so `psql` and `bun run | `bun run db:generate` | `prisma generate` | A fresh client after a schema edit or a branch switch | | `bun run db:studio` | `prisma studio` | A table browser on the database in `DATABASE_URL` | | `bun run db:migrate:deploy` | `prisma migrate deploy` | Apply pending migrations. It lives in `packages/db` alone, with no root passthrough | -| `FREENARY_DB_PUSH=1 bun run db:push` | `prisma db push` | A scratch database only. The gate variable is deliberate | +| `bun run db:push` | `prisma db push` | A scratch database only. It writes no migration history, so it drifts a migrated database | [Data model](./data-model.md#migrations) holds the migration rules and the whole list of migrations. @@ -226,7 +231,7 @@ Read the code in that inbox, then type it in the form. The local path sets no em | Tool | Version | Why you need it | | --- | --- | --- | -| Bun | `1.3.14` | The runtime, the package manager and the test runner. `packageManager` in the root `package.json` pins it. | +| Bun | `1.3.14` | The runtime, the package manager and the test runner. `packageManager` in the root `package.json` pins it locally. CI installs `bun-version: latest` instead. | | Docker | Any current release | Both stack paths run PostgreSQL in a container. | | Port `80` | Free, or held by a Traefik on the `devproxy` network | The containerised path only. One shared proxy on that port serves every stack. | | PostgreSQL | `18` | The image `bun run db:start` starts for you. You install nothing by hand. | @@ -283,37 +288,31 @@ bun run build | 6 | Environment | `bun run env:check` | `scripts/env-sync.ts --check`, which compares `.env.example` and the configuration reference against `packages/env/src/schema.ts` | | 7 | Build | `bun run build` | `turbo run build`, which builds the three applications | -The `compose` job needs no Bun and no install. It checks five things: +The `compose` job needs no Bun and no install. It runs `docker compose config --quiet` eight times: -- `docker compose -f docker-compose.dev.yml config --quiet` parses the development stack. -- The same command with `-f docker-compose.mail.yml` added parses the Mailpit layer over it. -- The same command against `docker-compose.proxy.yml` parses the development proxy. -- The same command against `docker-compose.yml` parses the production stack, with a temporary `.env` that sets `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. -- The same command with an empty `.env` must fail. A pass means the production file accepted two empty secrets, so the job fails. +- Against `docker-compose.dev.yml`, the development stack. +- With `-f docker-compose.mail.yml` added, the Mailpit layer over it. +- Against `docker-compose.proxy.yml`, the development proxy. +- Against `docker-compose.yml`, the production stack, with a temporary `.env` that sets `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. +- Against `docker-compose.yml` with an empty `.env`, which must fail. A pass means the production file accepted two empty secrets, so the job fails. +- Against `deploy/docker-compose.yml`, with a temporary `deploy/.env` that sets `FREENARY_URL`, `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. +- Against `deploy/docker-compose.yml` with an empty `deploy/.env`, which must fail in the same way. +- Against `deploy/coolify.yml`, with the Coolify-only `exclude_from_hc` lines removed and the three `SERVICE_*` values set in the shell. -That job is the one CI check no root script covers. Reproduce it by hand before you change either compose file. +That job is the one CI check no root script covers. Reproduce it from `.github/workflows/ci.yml` before you change any of these compose files. > [!WARNING] `bun run check-types` reaches two workspaces only. `apps/server` and `@freenary/ui` declare the script. `apps/web` declares none. `apps/fumadocs` names its own script `types:check`, so `turbo run check-types` skips it. Type-check those two workspaces by hand before you push. ### Tests run by hand, one file at a time -CI runs no tests. The root `package.json` and all ten workspace manifests declare no `test` script, and `turbo.json` declares no `test` task. Run the files your change touches: +CI runs no tests. No `package.json` in the repository declares a `test` script, and `turbo.json` declares no `test` task. Run the files your change touches: ```bash bun test scripts/dev-identity.test.ts bun test packages/api/src ``` -The repository holds 50 test files outside `node_modules`, each named `*.test.ts` or `*.test.tsx`: - -| Location | Test files | -| ---------------- | ---------- | -| `packages/api` | 28 | -| `apps/web/src` | 17 | -| `packages/ui` | 2 | -| `packages/auth` | 1 | -| `packages/email` | 1 | -| `scripts` | 1 | +Test files are named `*.test.ts` or `*.test.tsx`. Assertions sit inside `it()` or `test()`. Suites stay flat, no test uses a done callback, and no `.only` or `.skip` reaches a commit. @@ -342,13 +341,13 @@ A comment therefore never holds a fact. `docs/engineering/` in the repository ho `lefthook.yml` declares one hook. On `pre-commit` it runs three jobs in parallel: -| Job | Command | -| ---------- | ---------------------------------- | -| `oxlint` | `bun oxlint --fix {staged_files}` | -| `oxfmt` | `bun oxfmt --write {staged_files}` | -| `env-sync` | `bun run env:sync` | +| Job | Command | +| --- | --- | +| `oxlint` | `bun oxlint --fix --no-error-on-unmatched-pattern {staged_files}` | +| `oxfmt` | `bun oxfmt --write {staged_files}` | +| `env-sync` | `bun run env:sync` | -All three set `stage_fixed: true`, so each job re-stages the files it changed. The `env-sync` job runs on a change to the environment schema, a compose file or the generator. The repository declares no `pre-push` hook and no `commit-msg` hook. +All three set `stage_fixed: true`, so each job re-stages the files it changed. The `oxlint` job runs only when a JavaScript or TypeScript file is staged. The `env-sync` job runs on a change to the environment schema, a compose file or the generator. The repository declares no `pre-push` hook and no `commit-msg` hook. ## Branches, commits and pull requests @@ -423,7 +422,7 @@ Check an interface change in the browser, in light mode and in dark mode. A tag | Source | Scope | | --- | --- | -| [`.github/CONTRIBUTING.md`](https://github.com/suiramdev/freenary/blob/main/.github/CONTRIBUTING.md) | Setup, branch names, the pre-pull-request checks, the pull request rules, the sign-off and the license of a contribution | +| [`.github/CONTRIBUTING.md`](https://github.com/suiramdev/freenary/blob/main/.github/CONTRIBUTING.md) | Branch names, the pre-pull-request checks, the pull request rules, the sign-off and the license of a contribution | | [`CONTEXT.md`](https://github.com/suiramdev/freenary/blob/main/CONTEXT.md) | The product vision and the glossary of each domain term | | [`AGENTS.md`](https://github.com/suiramdev/freenary/blob/main/AGENTS.md) | The repository-wide working rules, and the documentation duty | | [`LICENSE`](https://github.com/suiramdev/freenary/blob/main/LICENSE) | The GNU Affero General Public License, version 3, under which Freenary is distributed | diff --git a/docs/engineering/releasing.md b/docs/engineering/releasing.md index 102c8c3..837dcb0 100644 --- a/docs/engineering/releasing.md +++ b/docs/engineering/releasing.md @@ -120,7 +120,7 @@ The snapshot lands through a pull request into `dev`. The `tag` job refuses a st The folder therefore freezes when that pull request merges, and the release comes later. A documentation change merged in between reaches `next` and never reaches the frozen folder, and no gate reports it. Snapshot last, and the window stays empty. -The copy pins every moving reference. A repository link names `main`, and the frozen page names the release tag. A download URL names `main`, and the frozen page names the tag. A `FREENARY_VERSION=main` line names the branch, and the frozen page names `X.Y`. Two rules follow: +The copy pins every moving reference. A repository link names `dev`, and the frozen page names the release tag. A download URL names `dev`, and the frozen page names the tag. A ``Branch: `dev` `` deploy step names `dev`, and the frozen page names `main`. A `FREENARY_VERSION=dev` line names the branch, and the frozen page names `X.Y`. Two rules follow: - A patch release finds `content/docs/X.Y` in place, and copies nothing. A documentation fix for a released version therefore edits that folder directly. - A pre-release copies nothing. Its readers stay on `next`. diff --git a/docs/engineering/writing-docs.md b/docs/engineering/writing-docs.md index 7be4cdc..434b093 100644 --- a/docs/engineering/writing-docs.md +++ b/docs/engineering/writing-docs.md @@ -44,7 +44,7 @@ A maintainer freezes `next` before a release, in a pull request to `dev`: cd apps/fumadocs && bun run docs:snapshot 1.2.0 ``` -The script takes the release version and writes the `1.2` folder. It refuses a pre-release, and it leaves the pages alone when the folder already exists. It labels the copy, adds it to the root `meta.json`, and pins every moving reference in the copy: a repository link and a `raw.githubusercontent.com` download URL take the release tag, and a `FREENARY_VERSION=main` line takes `1.2`. `docs:check` fails a frozen page that still holds one of these. The `Release` workflow refuses a stable version without its folder. See [Releasing](./releasing.md). +The script takes the release version and writes the `1.2` folder. It refuses a pre-release, and it leaves the pages alone when the folder already exists. It labels the copy, adds it to the root `meta.json`, and pins every moving reference in the copy: a repository link and a `raw.githubusercontent.com` download URL take the release tag, a ``Branch: `dev` `` deploy step takes `main`, and a `FREENARY_VERSION=dev` line takes `1.2`. `docs:check` fails a frozen page that still holds one of these. The `Release` workflow refuses a stable version without its folder. See [Releasing](./releasing.md). ## Where a page goes @@ -69,6 +69,7 @@ Every path is inside a version folder. Each folder's `meta.json` holds the order - Second person, present tense, active voice. A natural tone: contractions and `-ing` forms are fine. - Short sentences and short paragraphs. `docs:check` warns on a sentence over 35 words. +- No filler and no sales words: "note that", "it's worth noting", "in summary", "seamless", "robust", "powerful", "simply run" and the like. Delete the phrase, or state the fact it stands in for. `docs:check` warns on each one (`no-filler`), and `FILLER_PHRASES` in `apps/fumadocs/scripts/docs-rules.ts` holds the list. - Lead with what the reader wants to do, then how. Explain why a step exists in one clause, not a paragraph. - State the page's purpose in its first two lines. - One term per concept (see [Terminology](#terminology)). Explain jargon at first use, or avoid it. @@ -179,7 +180,7 @@ An internal link is an absolute site path with no extension and no version: | A heading on a page | `/docs/run/email#smtp` | | A folder's index page | the folder path itself | -`docs:check` resolves every link and `#anchor` against the real page tree. A relative `.mdx` link doesn't work. Link a repository file with its full URL under `https://github.com/suiramdev/freenary/blob/main/`; the snapshot pins `main` to the release tag in a frozen copy. +`docs:check` resolves every link and `#anchor` against the real page tree. A relative `.mdx` link doesn't work. Link a repository file with its full URL under `https://github.com/suiramdev/freenary/blob/dev/`, the branch that holds the unreleased code; the snapshot pins `dev` to the release tag in a frozen copy. Some anchors are linked from the app itself, in `apps/web/src/pages/setup/model/labels.ts`. Keep them, or update that file in the same pull request. @@ -218,11 +219,11 @@ Add every new page to `pages`. `docs:check` fails a page the array omits, and an | A page missing from `meta.json`, or an entry with no page | Passes | Fails | | A moved page with no pair in `moved-pages.ts` | Passes | Fails | | A version folder without `"root": true`, or with a stale title | Passes, drops out of the dropdown | Fails | -| A moving reference (`blob/main`, a `main` download URL, `FREENARY_VERSION=main`) in a frozen version | Passes | Fails | +| A moving reference (`blob/dev`, a `dev` download URL, ``Branch: `dev` ``, `FREENARY_VERSION=dev`) in a frozen version | Passes | Fails | | An operator code fence in `use/` | Passes | Fails | -| An environment variable in `use/`, roadmap language, a sentence over 35 words | Passes | Warns (`next` only) | +| An environment variable in `use/`, roadmap language, filler or a sales word (`no-filler`), a sentence over 35 words | Passes | Warns (`next` only) | -Add `--strict` to `docs:check` to fail on warnings too. `apps/fumadocs/scripts/check-docs.ts` holds the checks, `docs-rules.ts` the lists. +Add `--strict` to fail on warnings too. It works only from `apps/fumadocs` (`cd apps/fumadocs && bun run docs:check --strict`); at the root, turbo rejects the flag. `apps/fumadocs/scripts/check-docs.ts` holds the checks, `docs-rules.ts` the lists. A page that has to quote a banned phrase can switch a rule off for itself: @@ -234,7 +235,7 @@ Use it for a page that quotes the rules, never to pass the gate on ordinary pros ## Before you open the pull request -1. Read the code that owns the behaviour. Quote the real names. +1. Read the code that owns the behaviour. Quote the real names. Treat the existing page as a claim to check, not a source: verify it against the code, and delete what is wrong or redundant rather than rewording it. 2. Run `bun run docs:check` and `bun run build` in `apps/fumadocs` until both are silent. 3. If you changed `packages/env/src/schema.ts`, run `bun run env:sync`: it regenerates the variable tables in `reference/configuration.mdx`. 4. Open every page you changed with `bun run dev`. diff --git a/packages/db/AGENTS.md b/packages/db/AGENTS.md index 29cb1ef..f3ac2a2 100644 --- a/packages/db/AGENTS.md +++ b/packages/db/AGENTS.md @@ -7,7 +7,7 @@ Single-database data layer backed by Prisma ORM. ## Migrations (strict) -Schema changes ship as **versioned migration files** and are applied with `db:migrate`. That is the only supported mechanism: dev bootstrap and runtime provisioning both migrate, so every database carries a recorded history. `db:push` is opt-in dev tooling (see below), never the way schema reaches a shared database. +Schema changes ship as **versioned migration files** and are applied with `db:migrate`. That is the only supported mechanism: dev bootstrap and runtime provisioning both migrate, so every database carries a recorded history. `db:push` is dev tooling for a scratch database (see below), never the way schema reaches a shared database. **Every schema edit follows this loop:** @@ -27,12 +27,12 @@ Schema changes ship as **versioned migration files** and are applied with `db:mi - **Never edit or delete a migration once it has shipped** (merged, or applied anywhere). Fix forward with a new named migration. - **A schema edit and its migration land in the same commit/PR** — never one without the other. -## `db:push` is opt-in +## `db:push` is for scratch databases -`db:push` diffs the schema straight onto a database with no version history and would drift a migrated database. It is not the setup mechanism, and its CLI is gated behind an explicit opt-in: +`db:push` diffs the schema straight onto a database with no version history and would drift a migrated database. It is not the setup mechanism, and nothing stops it from running against a migrated database: ```bash -FREENARY_DB_PUSH=1 bun run db:push # scratch/local database only +bun run db:push # scratch/local database only ``` Use it only for fast iteration on a throwaway local database; before committing, capture the change as a named migration per the loop above. diff --git a/packages/env/src/schema.ts b/packages/env/src/schema.ts index a4e5056..b128cf7 100644 --- a/packages/env/src/schema.ts +++ b/packages/env/src/schema.ts @@ -157,7 +157,7 @@ export const serverEnvSchema = { .meta({ example: "postgresql://postgres:password@localhost:5432/freenary", onError: - "An empty value stops the start. An unreachable host fails at the first query, not at boot, because the driver connects late. In the Docker stack Compose builds this value from `POSTGRES_PASSWORD`.", + "An empty value stops the start. An unreachable host stops it too, because the server reads the database at startup. In the Docker stack Compose builds this value from `POSTGRES_PASSWORD`.", productionRequired: true, secret: true, section: "database", diff --git a/turbo.json b/turbo.json index 307d252..67b9f88 100644 --- a/turbo.json +++ b/turbo.json @@ -21,7 +21,9 @@ "content/**", "scripts/**", "src/shared/ui/mdx.tsx", - "src/shared/lib/versions.ts" + "src/shared/lib/versions.ts", + "src/shared/config/moved-pages.ts", + "src/shared/config/site.ts" ] }, "lint:fsd": {