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 a per-plugin PostgreSQL role guards it for
the plugin's server-side code: 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. A plugin's UI code is different: for the five bundled plugins still in the
app's own page it runs in the app's origin as the signed-in user, so
requiredTables doesn't restrict it, and even the server-side guard is a guardrail rather than a sandbox (see
Trust boundary for UI plugins).
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.
Trust boundary for UI plugins
Rewritten in 0.6.0. The sandbox is new and has not been independently reviewed.
A plugin's UI runs in one of two places, decided by the API
(pluginRuntimeFor): the app's own page, or a sandboxed frame. Only a bundled
plugin on a short host-internal list can run in the page. Insights, Spending
Trends, Upcoming, Forecast and Debt payoff are not on it (the last three moved
off it in 0.7.0); Budgets and Import CSV are, and run fully trusted, in the
host's origin, sharing the page, its memory and the signed-in user's session.
For them the scoped client, the x-wickermoney-plugin header and the manifest
check in ctx.api catch mistakes early; they are not a security boundary.
Everything else runs in an <iframe sandbox="allow-scripts"> (never
allow-same-origin), loaded from GET /plugin-frame, which the API serves with
its own content security policy (sandbox allow-scripts; default-src 'none'; connect-src 'none'). The frame has an opaque origin: no cookies, storage or DOM
of the host, and no network of its own for script. Its only way out is a
message channel to a broker in the host page, the one piece of code that holds a
plugin token. The broker checks the manifest grants and a path allow-list, then
the per-user response cache, then the network, and rate-limits and validates
every message. A plugin token is a signed five-minute JWT with its own audience,
carrying the plugin id and its granted tables. The server classifies every
/api/v1 route (auth/pluginRouteAccess.ts): a token reaches only the granted
GET /core/* reads and the plugin's own /p/<id>/ routes, a route left out of
the table is denied, and a test fails when one is. A frame costs time and
memory: roughly 6 to 9 MiB and 150 ms per frame after the first, and a first
frame starts its own renderer process.
Added in 0.7.0. A frame can be a widget or a whole page. Forecast and Debt
payoff are the first page frames: the frame is as tall as its content (the host
clamps a page to 240 to 20,000 px and a widget to 96 to 800 px), the window
scrolls, and the page title, the one main landmark and the skip link stay the
host's. A page's address is matched with everything under it, and the frame is
told the rest. A widget's frame is not created until its card is within 600 px
of the viewport; pages never wait. While a page's modal <dialog> is open, the
host lifts the frame to a fixed layer over the content area (never over the
navigation), driven by two additive messages, modal and modal-layout. The
frame's sandbox has no allow-forms, so the frame runtime submits forms itself
by dispatching the submit event; the sandbox is not widened. The host's
notification toast is not available to plugins: a plugin page keeps its own
inline status messages.
Known gaps, observed in Chromium 141 (WebKitGTK 2.52 for the last one only): the
frame's policy does not govern <link rel=prerender> or WebRTC, and a frame can
navigate itself to a same-origin URL the page's frame policy allows. A malicious
plugin can cause some credential-less requests and read nothing back; see
How much a plugin is trusted.
Server-side plugin code, which means bundled plugins only, runs under its own
PostgreSQL role, and the plugin query runner refuses statements that change the
role or session settings. That is a guardrail, not a sandbox. The runner
reads SQL text, so crafted SQL (a DO block that builds RESET ROLE at run
time, for instance) can return to the application role and lose the manifest's
table limits. Row-level security still holds, because the tenant context is
signed: such a statement reads no other user's rows. A connection the plugin
cannot leave would close that gap; it does not exist, so server-side plugin
code stays first-party.
A third-party UI plugin can be added by an operator by hand: an https origin in
PLUGIN_REMOTE_ORIGINS, and a manifest with a Subresource Integrity pin for
every file of the build in PLUGIN_MANIFEST_DIR. It runs only in a frame, with
no server code, and PLUGIN_REMOTE_ORIGINS stays empty by default. There is no
install flow, catalog or signing, and an independent review of the broker and
token path is required before any of those.
Interpreting money is a plugin's job
The line between core and plugin is about what the code does, not how big or
central it is. Core stores and moves money: accounts, transactions, transfers,
and since 0.2.0 recurring items, which are the user's own statement of what
they expect to happen. Anything that turns that data into an opinion is a
plugin. The "Until payday" widget (plugins/upcoming) and the Forecast page
(plugins/forecast) are both bundled plugins for this reason, even though they
ship enabled and read core data. Each holds only read grants on
recurring_items and accounts, the same as a third-party plugin would have
to request.
The projection itself is computed by core and served from
/api/v1/core/recurring-items/upcoming and /forecast, because "today", the
starting balance and the buffer are core facts. The plugins only present it.
"Today" comes from the server
Every date-dependent answer (a recurring item's next due date, the Until
payday window, the first forecast day) is computed on the server against the
user's today: their calendar day in core.users.timezone. That value is
returned in the response. Clients and plugins render the server's today and
never derive it from the browser clock, so two widgets on one dashboard can't
disagree about the date. Calendar dates travel as YYYY-MM-DD strings and are
built in local time when displayed (ctx.formatDate); parsing one as midnight
UTC shows it a day early anywhere west of UTC, which was a real bug fixed in
0.2.0.
The zone is an IANA name, stored canonically. Fixed offsets are refused: they have no daylight saving time, and PostgreSQL reads their sign the opposite way to ISO 8601.
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. The recurrence maths is
an example: occurrence dates, month-end clamping, nextPayday,
monthlyEquivalent and daily balances with a per-day low live in
@wickermoney/plugin-sdk/recurrence (Apache-2.0, dependency-free), so the
server, the bundled plugins and any third-party plugin use the same rules.
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.