Skip to content
Merged
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
47 changes: 42 additions & 5 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,22 @@ 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`), `claimedAt?`, `completedAt?`, `createdAt` | At most one row, for the whole setup lifecycle. `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 runs in the better-auth `session.create.after` hook, at each new session. It inserts the row with `createMany` and `skipDuplicates`. Then one `updateMany` on `instance_setup`, matched on `claimedAt: null`, runs inside the transaction that sets `User.role`. That conditional update is the lock. A second session matches no row, so two accounts cannot both become the operator.

The migration that creates the table sets `claimedAt` and `completedAt` on an instance that already has users. It gives the role to the oldest user with `emailVerified`, or to the oldest user when none is verified.

## 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,9 +221,32 @@ 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 first account that signed in to the instance. It opens the setup screen, and it reads no financial row of another user |

Two code paths set `OPERATOR`: one `user.update` inside the claim transaction, and the backfill `UPDATE` in the migration `20260922120000_instance_configuration`. The SQL in [Troubleshooting](/docs/self-hosting/troubleshooting#the-wrong-account-became-the-operator) can also move the role to another account. 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`.
`BudgetLineKind` was another enum 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`.

### Free-text vocabularies

Expand Down
33 changes: 21 additions & 12 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 @@ -137,9 +128,21 @@ Open `http://localhost:3001` in a browser. The screen reads `Welcome to Freenary
2. Write a name of 2 characters or more, and a password of 12 characters or more.
3. Press `Create account`.

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.
With no email provider configured, Freenary signs you in at once and opens `/setup`. 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, then opens `/setup`.

</Step>

<Step>

### Add the credentials

The first account to sign in becomes the operator of this instance. Freenary opens `/setup` for it, and no second account can take the role.

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.

Freenary has no admin account and no owner role. The first account is an ordinary user.
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 onboarding wizard opens next.

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

</Step>

Expand All @@ -149,6 +152,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 +174,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
13 changes: 13 additions & 0 deletions apps/fumadocs/content/docs/next/self-hosting/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ 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 */}
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
33 changes: 21 additions & 12 deletions apps/fumadocs/content/docs/next/self-hosting/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Put your own hostnames in the last two lines. The API server refuses a secret un

### Set up the bank provider

Do this before the first start, so the instance serves working screens from the beginning.
Do the console work before the first start, so the instance serves working screens from the beginning.

Create a sandbox domain and a client application in the [Powens console](https://console.powens.com). Register this redirect URI on the client:

Expand All @@ -120,16 +120,9 @@ The value is your `CORS_ORIGIN` and the path `/callback/powens`. Powens matches

Activate the connectors of the banks you want to offer. A domain with no active connector answers with an empty bank list.

Then add the credentials of the client to `.env`:
Keep the domain, the client id and the client secret to hand. The credentials go into [the setup screen](/docs/self-hosting/setup) after the first start, and not into the `.env` file.

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

[Bank providers](/docs/self-hosting/bank-providers) holds each console step, the sandbox limits, and the Enable Banking alternative. An empty `POWENS_` variable turns bank linking off, and no message says so.
[Bank providers](/docs/self-hosting/bank-providers) holds each console step, the sandbox limits, and the Enable Banking alternative. An instance with no credentials turns bank linking off, and no message says so.

</Step>

Expand Down Expand Up @@ -187,9 +180,19 @@ docker compose logs -f server

### Create the first account

Open the web app origin in a browser, then sign up with an email address and a password. Freenary has no admin account and no owner role, so the first account is an ordinary user. [First steps](/docs/guides/first-steps) covers what that user does next.
Open the web app origin in a browser, then sign up with an email address and a password. An instance on a public origin is open to anybody who finds it. Create that account in the same sitting as the install.

</Step>

<Step>

The wizard that opens proves the bank provider. It asks for a tax residency, then offers a bank step with the banks your domain activated. A missing bank step means Freenary read no credentials, and Settings then reads `Bank linking is unavailable`.
### Open the setup screen

The account you made is the operator of the instance, because it signed in first. Freenary opens `/setup` for it. [The setup screen](/docs/self-hosting/setup#the-first-account-becomes-the-operator) tells more.

The setup screen is where the bank and email credentials go. [The setup screen](/docs/self-hosting/setup) walks each card and the restart that applies it.

After `Finish setup`, the onboarding wizard opens, and it proves the bank provider. It asks for a tax residency, then offers a bank step with the banks your domain activated. A missing bank step means Freenary read no credentials, and Settings then reads `Bank linking is unavailable`. [First steps](/docs/guides/first-steps) covers what the operator does next.

</Step>

Expand All @@ -203,6 +206,12 @@ The first sign-up is the moment to decide about email verification. With no emai

<Cards>

<Card
title="The setup screen"
href="/docs/self-hosting/setup"
description="Become the operator, then set the providers from the app."
/>

<Card
title="Configuration"
href="/docs/self-hosting/configuration"
Expand Down
1 change: 1 addition & 0 deletions apps/fumadocs/content/docs/next/self-hosting/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"pages": [
"index",
"configuration",
"setup",
"reverse-proxy",
"scaling",
"authentication",
Expand Down
Loading
Loading