Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .docker/dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ COPY packages/config/tsconfig.base.json ./packages/config/
COPY packages/db/package.json ./packages/db/
COPY packages/email/package.json ./packages/email/
COPY packages/env/package.json ./packages/env/
COPY packages/instance-config/package.json ./packages/instance-config/
COPY packages/ui/package.json ./packages/ui/

RUN bun install --frozen-lockfile --ignore-scripts
Expand Down
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
POSTGRES_PASSWORD=password
# Signs session cookies, encrypts two-factor secrets and backup codes, and signs the bank connection state.
BETTER_AUTH_SECRET=dev_secret_change_me_at_least_32chars
# The setup token that claims an unclaimed instance on the setup screen. Unset, the server makes a new token at each start and prints it in its log.
# FREENARY_SETUP_TOKEN=choose_a_long_random_value_of_32_or_more_characters

# --- Public origins and cookies ---
# The origins a browser reaches. Both default to localhost outside production,
Expand Down
43 changes: 39 additions & 4 deletions apps/fumadocs/content/docs/next/contributing/data-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This page owns the vocabulary of the schema. Every model name, column name and e

## Where the schema lives

Prisma reads a directory, not one file. Five files split the model by area.
Prisma reads a directory, not one file. Six files split the model by area.

<Files>
<Folder name="packages/db" defaultOpen>
Expand All @@ -19,9 +19,10 @@ Prisma reads a directory, not one file. Five files split the model by area.
<File name="budget.prisma" />
<File name="budget-profile.prisma" />
<File name="assistant.prisma" />
<File name="instance.prisma" />
</Folder>
<Folder name="migrations">
<File name="7 migration directories, migration_lock.toml, .gitkeep" />
<File name="12 migration directories, migration_lock.toml, .gitkeep" />
</Folder>
</Folder>
<File name="prisma.config.ts" />
Expand Down Expand Up @@ -49,7 +50,7 @@ Two places supply the URL. `packages/db/prisma.config.ts` hands `process.env.DAT

| Model and table | Key fields | Constraints and notes |
| --- | --- | --- |
| `User`, table `user` | `id`, `name`, `email`, `emailVerified` (`false`), `twoFactorEnabled` (`false`), `image?`, `taxCountries String[]`, `onboardingCompletedAt?`, `createdAt`, `updatedAt` | `@@unique([email])`. Ten child relations, each with a cascade delete |
| `User`, table `user` | `id`, `name`, `email`, `emailVerified` (`false`), `twoFactorEnabled` (`false`), `image?`, `taxCountries String[]`, `onboardingCompletedAt?`, `role UserRole` (`MEMBER`), `createdAt`, `updatedAt` | `@@unique([email])`. Ten child relations, each with a cascade delete. Better Auth never writes `role` |
| `Session`, table `session` | `expiresAt`, `token`, `ipAddress?`, `userAgent?`, `userId` | `@@unique([token])`, `@@index([userId])`, cascade |
| `Account`, table `account` | `issuer`, `accountId`, `providerId`, `userId`, `accessToken?`, `refreshToken?`, `idToken?`, `accessTokenExpiresAt?`, `refreshTokenExpiresAt?`, `scope?`, `password?` | `@@unique([issuer, accountId], map: "account_issuer_accountId_uidx")`, `@@index([userId])`. The credential and OAuth link table, never a bank account. The password hash sits in `password` |
| `Verification`, table `verification` | `identifier`, `value`, `expiresAt` | `@@index([identifier])`. No user column and no foreign key |
Expand Down Expand Up @@ -156,9 +157,20 @@ The colour a subcategory shows is the stored colour of its parent, through `cust

The `ordinal` column orders a conversation and `createdAt` cannot, because both rows of one turn can share a millisecond. The `parts` column holds the AI SDK message parts as the stream wrote them. So a stored answer replays with its lookup rows intact.

## Instance configuration

`instance.prisma` holds what an operator sets in the setup screen, and the claim that names the operator. Neither model has a user relation, and neither one cascades.

| Model and table | Key fields | Constraints and notes |
| --- | --- | --- |
| `InstanceSetting`, table `instance_setting` | `key` (`@id`), `sealed`, `updatedAt`, `updatedBy?` | One row per environment variable the screen owns. `sealed` is AES-256-GCM ciphertext under a key derived from `BETTER_AUTH_SECRET`, with the row `key` as associated data. `updatedBy` holds a `User.id` and carries no foreign key, so deleting the operator leaves the row |
| `InstanceSetup`, table `instance_setup` | `id` (`@id`, default `singleton`), `tokenHash?`, `claimedAt?`, `completedAt?`, `createdAt` | At most one row, for the whole setup lifecycle. `tokenHash` is the SHA-256 hex digest of the setup token, printed at start or read from `FREENARY_SETUP_TOKEN`, and the claim clears it. `claimedAt` marks the instance as owned; `completedAt` marks the wizard as finished, which is what stops the web app sending every visitor to `/setup` |

The claim is one `updateMany` on `instance_setup`, matched on the hash and on `claimedAt: null`, inside the transaction that sets `User.role`. That conditional update is the lock. A second caller matches no row and is refused, so two callers cannot both become the operator.

## Enums

PostgreSQL holds exactly three enum types. Every value below is verbatim, in declaration order.
PostgreSQL holds exactly five enum types. Every value below is verbatim, in declaration order.

### `BankConnectionStatus`

Expand Down Expand Up @@ -207,6 +219,29 @@ The last column is `isInvestmentAccountType`, a TypeScript lookup in `packages/a
| `USER` | The question of the reader |
| `ASSISTANT` | The answer |

### `UserRole`

`User.role` uses it, with the column default `MEMBER`.

| Value | Meaning |
| --- | --- |
| `MEMBER` | The ordinary account. It reaches its own rows alone |
| `OPERATOR` | The account that claimed the instance. It opens the setup screen, and it reads no financial row of another user |

One `user.update`, inside the claim transaction, is the only writer of `OPERATOR`. No endpoint writes the value back to `MEMBER`.

### `SyncPhase`

`SyncRun.phase` uses it, and the column has no default.

| Value | Meaning |
| --- | --- |
| `IMPORTING` | The run is reading accounts and transactions from the providers |
| `CATEGORISING` | The import finished, and the run is resolving categories |
| `FINISHED` | The run ended. `SyncRun.finishedAt` holds the moment |

One row per user, keyed by `userId`. A user therefore has one sync run at a time, and a new run replaces the last one.

### The dropped enum

`BudgetLineKind` was a fourth type with the values `REVENUE`, `INVESTMENT` and `OUTGOING`. The migration `20260904120000_derive_budget_line_kind` dropped the type together with the column `budget_line.kind`. The three names survive as a TypeScript union, and `budgetLineKindOf` derives the value from the group of a category. One group maps to each name: `income` to `REVENUE`, `investments` to `INVESTMENT` and `spending` to `OUTGOING`.
Expand Down
37 changes: 26 additions & 11 deletions apps/fumadocs/content/docs/next/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,7 @@ The console work takes four steps, and [Bank providers](/docs/self-hosting/bank-
3. Create a client application, and register `<CORS_ORIGIN>/callback/powens` as its redirect URI.
4. Activate the connectors of the banks you want. A domain with no active connector answers with an empty list.

Write the credentials into the same `.env` file:

```dotenv
BANKING_PROVIDER=powens
POWENS_DOMAIN=acme-sandbox
POWENS_CLIENT_ID=<client id>
POWENS_CLIENT_SECRET=<client secret>
```

A later change of these values needs `docker compose up -d`. Compose recreates the `server` container with the new environment, and no image rebuild happens.
Keep the domain, the client id and the client secret to hand. They go into the setup screen after the first start, and not into the `.env` file.

</Step>

Expand Down Expand Up @@ -139,7 +130,25 @@ Open `http://localhost:3001` in a browser. The screen reads `Welcome to Freenary

With no email provider configured, Freenary signs you in at once and opens the onboarding wizard. With one configured, the screen moves to `Confirm your email` and asks for the 6-digit code it sent you. That code lives 10 minutes. Freenary signs you in after you enter it.

Freenary has no admin account and no owner role. The first account is an ordinary user.
</Step>

<Step>

### Claim the instance, then add the credentials

The first account is an ordinary user until it claims the instance. Freenary opens `/setup` for it. Read the setup link from the server log:

```bash
docker compose logs server | grep "setup token"
```

Open the link, and press `Claim`. The link puts the token into the field for you. That account becomes the operator of this instance, and no second account can take the role.

Now go to the **Bank provider** step, pick `Powens`, and press `Continue`. Type the three values from the console, and press `Save and continue`. Freenary calls your Powens domain with the credentials, and saves them when the call succeeds.

Go to the last step, and press `Finish setup`. The server restarts to use the credentials, and the page opens the app when the server is back.

[The setup screen](/docs/self-hosting/setup) holds each card, each check and the last step in full.

</Step>

Expand All @@ -149,6 +158,7 @@ Freenary has no admin account and no owner role. The first account is an ordinar

- You are signed in to your own instance.
- Your account sits in the PostgreSQL database of that instance.
- You are the operator of the instance, so `/setup` opens for you and for nobody else.
- **Settings** lists the banks of your tax residencies, because the credentials of the provider are set.
- No bank is connected, so the Budget screen asks you to connect one.

Expand All @@ -170,6 +180,11 @@ Freenary has no admin account and no owner role. The first account is an ordinar
href="/docs/self-hosting"
description="Public origins, real secrets, and the checks that prove them."
/>
<Card
title="The setup screen"
href="/docs/self-hosting/setup"
description="Change a provider, check its credentials, and apply them."
/>
<Card
title="Configuration reference"
href="/docs/self-hosting/configuration"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,18 +201,19 @@ Type the `-p freenary-restore-check` flag in the `down -v` command. Without it,

## What a dump does not hold

A database dump holds no configuration. The root `.env` file sits on the host, next to `docker-compose.yml`. It is outside every volume, and `.dockerignore` keeps every `.env` file out of the image layers.
A dump holds what [the setup screen](/docs/self-hosting/setup) saved. It holds nothing from the `.env` file, which sits on the host next to `docker-compose.yml`. It is outside every volume, and `.dockerignore` keeps every `.env` file out of the image layers.

| Not in the dump | What you lose without it |
| --- | --- |
| `BETTER_AUTH_SECRET` | Every session, and every two-factor enrolment in the restored data |
| `BETTER_AUTH_SECRET` | Every session, every two-factor enrolment, and every row of `instance_setting` |
| `POSTGRES_PASSWORD` | Access to the volume the old container created with it |
| The bank provider credentials | The bank sync of every user |
| The email provider credentials | One-time code delivery, and therefore password reset |
| A provider credential set in `.env` | The bank sync, or the one-time code delivery, of that adapter |

The `instance_setting` table is in the dump. A provider credential typed into the setup screen therefore comes back with the data. Each row is sealed with a key made from `BETTER_AUTH_SECRET`, and opens under that secret alone.

<Callout type="error">

Keep `BETTER_AUTH_SECRET` with the dump. That one value signs the session cookie, and it encrypts the `secret` and `backupCodes` columns of the `two_factor` table. It also signs the bank connection state that round-trips through the provider. A restore under a new secret signs every user out and breaks every two-factor enrolment.
Keep `BETTER_AUTH_SECRET` with the dump. That one value signs the session cookie, and it encrypts the `secret` and `backupCodes` columns of the `two_factor` table. It also seals every `instance_setting` row, and signs the bank connection state that round-trips through the provider. A restore under a new secret signs every user out and breaks every two-factor enrolment. It also turns off every provider the setup screen configured.

</Callout>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Freenary never talks to a bank itself. A bank provider holds that connection, an

An unknown value fails the environment check at startup. A known value with no credentials turns bank linking off rather than half on.

An operator sets the provider and its credentials in [the setup screen](/docs/self-hosting/setup), not only in `.env`. The screen calls the provider with the credentials before it saves them. A value already set in the environment wins, and the screen marks it read-only.

**Choose Powens unless you have a reason not to.** It is the default, and it is the only adapter that imports accounts, balances and holdings. [Powens](#powens) below walks the console setup step by step. A sandbox domain costs nothing and needs no contract.

## The two adapters
Expand Down
16 changes: 15 additions & 1 deletion apps/fumadocs/content/docs/next/self-hosting/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,24 @@ The development stack reads the same file, and the file is optional there. `dock

`migrate` gets one key, `DATABASE_URL`. `postgres` gets three: `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD`.

## Where a value can be set

Some of these variables also live in [the setup screen](/docs/self-hosting/setup). An operator types them into a form there, instead of into the file:

| Integration | Variables the screen owns |
| --- | --- |
| 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` |

**A value in the environment wins.** The server reads a variable from the environment first, and falls back to the row the screen wrote. A line in `.env` therefore makes the matching field read-only, and the screen names the variable the environment owns.

Every other variable on this page comes from the environment alone.

## How the server checks its environment

{/* env-sync:start count */}

The API server checks its environment once, at startup. The schema is [`packages/env/src/schema.ts`](https://github.com/suiramdev/freenary/blob/main/packages/env/src/schema.ts). It declares 48 variables with Zod, and `packages/env/src/server.ts` hands them to `@t3-oss/env-core`. The reference below, and `.env.example`, are generated from that one file.
The API server checks its environment once, at startup. The schema is [`packages/env/src/schema.ts`](https://github.com/suiramdev/freenary/blob/main/packages/env/src/schema.ts). It declares 49 variables with Zod, and `packages/env/src/server.ts` hands them to `@t3-oss/env-core`. The reference below, and `.env.example`, are generated from that one file.

{/* env-sync:end */}

Expand Down Expand Up @@ -101,6 +114,7 @@ Outside production the same text is a `console.warn` line. Compose always sets `
| Variable | Default | What it does | Absent or wrong |
| --- | --- | --- | --- |
| `BETTER_AUTH_SECRET` | `dev_secret_change_me_at_least_32chars` outside production, required in production | Signs session cookies, encrypts two-factor secrets and backup codes, and signs the bank connection state. | Under 32 characters stops the start. The development default applies outside production alone, so a production start with no value stops. |
| `FREENARY_SETUP_TOKEN` | unset | The setup token that claims an unclaimed instance on the setup screen. Unset, the server makes a new token at each start and prints it in its log. | Under 32 characters, or a character other than a letter, a digit, `-` or `_`, stops the start. After the claim the server ignores the value. |
| `NODE_ENV` | `development` | Picks the runtime mode: `development`, `production` or `test`. | `production` drops the log file drain and turns the cookie warning into a refusal. `test` turns the sign-in rate limits off. Compose always sets `production`. |
| `PORT` | `3000` | The port the API server listens on. It also fills the `BETTER_AUTH_URL` default. | A non-digit character stops the start. In the Docker stack it must stay `3000`, because the healthcheck fetches port 3000 inside the container. Never put it in the root `.env` file, which the `server` service loads whole. |

Expand Down
2 changes: 2 additions & 0 deletions apps/fumadocs/content/docs/next/self-hosting/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Freenary checks `EMAIL_FROM` before `SMTP_HOST`. A stack that sets neither there

[Configuration](/docs/self-hosting/configuration) defines every variable named here.

An operator sets these values in [the setup screen](/docs/self-hosting/setup), and not only in the `.env` file. The screen opens the connection and signs in before it saves. A wrong password is therefore refused at the form, and not at the first one-time code. A value already set in the environment wins, and the screen marks it read-only.

## What stops with no adapter

These sign-in paths still work: email and password, passkeys, and two-factor with an authenticator app.
Expand Down
Loading
Loading