Skip to main content

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.