Architecture
A summary of the load-bearing decisions, condensed from the project's ADRs. This page exists so a contributor doesn't need private repo access to understand why the codebase is shaped the way it is — the full ADRs (with performance numbers and rejected alternatives) live alongside the app's source once a decision is ready to publish.
PostgreSQL is a hard requirement, not a preference
Wicker Money is a thin core plus installable plugins. Each plugin declares a
requiredTables manifest field, and the host enforces it: a plugin that asks
for transactions and categories must not be able to reach accounts, and
must see only the current user's rows within the tables it does get.
Every mechanism that enforcement needs is PostgreSQL-specific — a
non-superuser, non-owner application role holding only DML; row-level
security policies on every table carrying user_id; narrow
SECURITY DEFINER functions for the handful of operations that necessarily
precede authentication; a core schema plus one plugin_<id> schema per
plugin. SQLite has no equivalent for row-level security, roles, schemas or
SECURITY DEFINER — a SQLite connection owns the whole file, so isolation
would have to be enforced in application code instead of by the database,
which is a materially larger trust surface for third-party plugin code.
numeric(19,4) also has no real SQLite equivalent (its NUMERIC is a type
affinity, not an exact type), and money arithmetic that's subtly wrong is
worse than arithmetic that fails loudly. So: PostgreSQL only, no SQLite tier,
now or later. A read-only SQLite export is unaffected — that's a data
format, not a runtime backend.
Three layers: handler, service, repository
HTTP handler -> Service -> Repository
(thin: parse, (business (the only place SQL is written)
call, respond) rules)
|
UnitOfWork.forUser(userId, repos => ...)
- Repository — one interface plus one Kysely implementation per aggregate.
Methods are named operations, not generic query builders; they return plain
rows or
undefined, and never throw HTTP errors. - Service — business rules and orchestration. Takes a
UnitOfWork, calls several repositories inside one transaction, throws domain errors (NotFoundError,ConflictError,ValidationError). - UnitOfWork — opens one transaction, binds the tenant context so row-level security applies, hands out fresh repositories over that transaction.
An ESLint rule enforces the boundary: files under routes/ and service/
may not import Kysely or the connection helpers directly. Plugins apply the
same split internally against a host-provided query runner.
Signed tenant context
Binding "who's making this request" to a database session setting
(app.user_id) isn't a boundary on its own — any SQL running in that
transaction could reset the role and overwrite the setting to impersonate
another user, and PostgreSQL can't revoke set_config on a custom setting
from a non-superuser role. So the tenant context is signed: every
row-level-security check verifies app.user_id against an HMAC-SHA256
signature (app.user_sig) derived from a server-side key, computed inside a
SECURITY DEFINER function that closes to NULL — meaning "matches no
row" — on any malformed or missing input, rather than raising. This costs
roughly half a millisecond per statement (measured against ~100k seeded
transactions) and protects against RESET ROLE, session-setting tampering,
search_path shadowing and signature replay across users. It does not
protect against a plugin that regains the application role's own table
privileges after RESET ROLE — that's why the current model only installs
trusted plugins; genuine third-party plugin isolation needs connection-level
isolation that doesn't exist yet.
One image, one process
The API serves the built web app and the plugin remotes itself
(WEB_DIST_DIR), on the same origin as /api. This isn't a style choice —
the refresh token is a SameSite=Strict cookie scoped to /api/v1/auth, and
plugin remotes load from the same origin unless explicitly allow-listed
(PLUGIN_REMOTE_ORIGINS), both of which assume the UI and API share an
origin. One consequence: a reverse proxy is optional, not required, for a
self-hosted instance. See Self-hosting for the
practical side of this.
Licensing boundary
packages/plugin-sdk and packages/ui-kit are Apache-2.0 — the only two
packages published to npm. Everything under apps/* and plugins/* is
AGPL-3.0-only and private (shipped only inside the container image, never
published as a package). A plugin loads via Module Federation against a
manifest-declared remote entry; it does not import from apps/web or
apps/api at build or run time.
That split is deliberate: a plugin that depends on only the published
plugin-sdk/ui-kit surface — no import from apps/*, no copying of AGPL
code — can be licensed independently, including as closed-source or paid.
A plugin that imports AGPL-covered code, or is a derivative of a bundled
plugin, stays AGPL-3.0-only. This is why the plugin contract (what lives in
plugin-sdk versus what stays app-internal) is treated as a licensing
decision, not just an API design one — anything a plugin needs has to be
promotable into the SDK, never the other way around.
Contributions follow inbound-equals-outbound: a change to plugin-sdk or
ui-kit is Apache-2.0, a change to the app or a bundled plugin is
AGPL-3.0-only. The project uses the
Developer Certificate of Origin (sign
commits with git commit -s) rather than a CLA, so contributing doesn't
require assigning copyright or granting relicensing rights.