Multi-tenant financial tracking for teams, on the edge.
Track what comes in, what goes out and what is left, across as many workspaces as you belong to. Built as a real product rather than a dashboard demo: row-level security is the authorization boundary, totals are computed in Postgres, and the whole thing runs from a single Cloudflare Worker.
Quick start · Architecture · Security model · API · Deployment
Most personal finance tools assume one person and one ledger. The moment a second person is involved, a studio tracking client retainers or a household splitting bills, you are back to a spreadsheet nobody trusts.
Zefinoro treats the workspace as the unit. A user belongs to many workspaces, each with its own ledger, members, categories, invitations and files. The hard part of that is not the UI, it is making certain that workspace A can never see workspace B, including when a controller has a bug. This project puts that guarantee in the database rather than in application code.
| Capability | What it does |
|---|---|
| Multi-workspace | Belong to many workspaces, switch instantly, no data bleeds between them |
| Role-based access | Owner, admin and member, enforced in Postgres policies rather than in the UI |
| Invitations | Cryptographically generated codes with expiry, use limits, revocation and audit trail |
| Transaction ledger | Received and spent entries with title, description, amount, currency, category and date |
| File attachments | Images, PDFs, video, audio, documents and archives, uploaded straight to storage |
| In-browser previews | Type-aware viewers for images, PDFs, video, audio, text, CSV and JSON |
| Dashboard analytics | Server-computed totals with period-over-period deltas and interactive charts |
| Billing workspace | Sortable, filterable and paginated table with searching and column visibility |
| Command palette | Keyboard-first navigation and actions on Ctrl or Cmd + K |
| Light and dark themes | Design tokens throughout, no hard-coded colours |
| Google and email auth | Supabase Auth, with identity linking so both methods reach one account |
Requires Node.js 22 or newer, a Supabase project and a Cloudinary account.
git clone https://github.com/spacesdrive/zefinoro.git
cd zefinoro
npm run install:all
cp .env.example frontend/.env # VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY
cp .env.example backend/.dev.vars # server-side values
npx supabase link --project-ref <your-project-ref>
npx supabase db push # tables, indexes, RLS policies, RPCs, seed categoriesThen start both processes:
npm run dev:api # Worker on http://localhost:8787
npm run dev:web # SPA on http://localhost:5173, proxying /api to the WorkerSign up, create a workspace, and record a transaction. Full variable documentation is in
.env.example.
One Cloudflare Worker serves both the JSON API and the compiled single-page app, so the browser talks to a single origin and CORS never enters the picture.
flowchart LR
B["Browser<br/>React 19 + TanStack Query"]
W["Cloudflare Worker<br/>Hono router + static assets"]
S["Supabase Postgres<br/>row level security"]
R["Upstash Redis<br/>analytics cache"]
C["Cloudinary<br/>file storage"]
B -->|JWT| W
W -->|"same JWT, queries as the user"| S
W -->|"read through, fails open"| R
B -->|"signed direct upload"| C
W -->|"signature and deletion only"| C
Two decisions shape everything else.
The Worker holds no service-role key. It forwards the caller's JWT to Supabase, so every query runs as that user and row-level security applies exactly as it would to a direct connection. A mistake in a controller cannot read another tenant's ledger, because the database refuses.
Files never pass through the Worker. A 25 MiB video has no business occupying Worker memory. The browser requests a short-lived signature, uploads directly to Cloudinary, and returns the resulting metadata for the API to validate and record.
zefinoro/
├── backend/ Hono API on Cloudflare Workers
│ └── src/
│ ├── index.ts Worker entry: API under /api, SPA everywhere else
│ ├── routes/ Route table and middleware composition
│ ├── controllers/ One module per resource
│ ├── middleware/ auth, workspace authorization, validation, rate limiting
│ ├── lib/ supabase, redis, cloudinary, files, dates, errors
│ ├── schemas/ Zod request contracts
│ └── types/ Worker bindings and database row types
├── frontend/ React single-page application
│ └── src/
│ ├── app/ Shell, router, query client
│ ├── routes/ Page components
│ ├── components/ ui, layout, dashboard, billing, files, users
│ ├── features/ Per-domain API clients and query hooks
│ ├── contexts/ auth, workspace, theme
│ └── schemas/ Zod form contracts
└── supabase/migrations/ Schema, RLS policies, RPCs
Eight tables. Membership is a row rather than a column, which is what makes roles extensible and lets a single user hold different roles in different workspaces.
erDiagram
profiles ||--o{ workspace_members : "belongs to"
workspaces ||--o{ workspace_members : "has"
workspaces ||--o{ transactions : "owns"
workspaces ||--o{ categories : "owns"
workspaces ||--o{ workspace_invitations : "issues"
workspaces ||--|| workspace_settings : "configures"
transactions ||--o{ transaction_attachments : "carries"
categories ||--o{ transactions : "classifies"
profiles ||--o{ transactions : "records"
Amounts are stored as numeric with a separate currency column, never as a formatted string, so
arithmetic stays exact and the display layer is free to change. Indexes cover the access patterns
the dashboard actually uses: by workspace, type, transaction date and category.
| Control | Implementation |
|---|---|
| Tenant isolation | Row-level security on every table, with SECURITY DEFINER membership helpers that avoid policy recursion |
| Workspace resolution | The identifier in a URL is resolved to a verified membership before any controller runs |
| Non-member response | 404 rather than 403, because confirming a workspace exists is itself a disclosure |
| Invite codes | Generated in Postgres via gen_random_bytes, from an alphabet without ambiguous characters |
| Redemption races | Redeemed inside a locked transaction, so a single-use code cannot be consumed twice |
| Last owner | A trigger prevents a workspace from losing its final owner |
| Upload validation | Extension, MIME type and size are re-checked server-side, and mismatches are rejected |
| Storage allow-listing | Attachment URLs that do not belong to the configured cloud are refused before storage |
| Rate limiting | Invite creation and preview, workspace creation and upload signing |
| Bundle contents | Only the Supabase URL and anon key reach the browser, both safe to publish |
sequenceDiagram
participant U as User
participant A as SPA
participant S as Supabase Auth
participant W as Worker
U->>A: Sign in with email or Google
A->>S: Authenticate
S-->>A: Session with JWT
A->>W: GET /api/workspaces (Bearer JWT)
W->>S: Verify token, then query as that user
S-->>W: Only rows the policies permit
W-->>A: Workspaces
alt No workspace yet
A->>U: Onboarding, create or join
else One or more
A->>U: Dashboard for the selected workspace
end
All endpoints live under /api. Everything below /workspaces/:workspaceId passes through
authorization middleware first, so a new endpoint is protected by construction.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Liveness probe, the only public route |
GET PATCH |
/me |
Read and update the signed-in profile |
GET POST |
/workspaces |
List memberships, create a workspace |
POST |
/workspaces/join |
Redeem an invitation code |
GET |
/invitations/preview |
Validate a code without consuming it, rate limited |
GET PATCH DELETE |
/workspaces/:id |
Read, update, delete a workspace |
POST |
/workspaces/:id/leave |
Leave, unless you are the last owner |
GET PATCH |
/workspaces/:id/settings |
Workspace preferences |
GET |
/workspaces/:id/stats |
Totals with period-over-period deltas |
GET |
/workspaces/:id/series |
Received against spent, bucketed over time |
GET |
/workspaces/:id/breakdown |
Totals grouped by category |
GET |
/workspaces/:id/recent |
Latest transactions |
GET POST |
/workspaces/:id/transactions |
List with filters, create |
GET PATCH DELETE |
/workspaces/:id/transactions/:txId |
Single transaction |
POST |
/workspaces/:id/uploads/sign |
Short-lived upload signature |
GET POST |
/workspaces/:id/transactions/:txId/attachments |
List and attach files |
DELETE |
/workspaces/:id/attachments/:attachmentId |
Remove a file and its stored asset |
GET POST |
/workspaces/:id/categories |
List and create categories |
PATCH DELETE |
/workspaces/:id/categories/:categoryId |
Manage a category |
GET |
/workspaces/:id/members |
List members |
PATCH DELETE |
/workspaces/:id/members/:memberId |
Change role, remove member |
GET POST |
/workspaces/:id/invitations |
List and create invitations |
POST |
/workspaces/:id/invitations/:inviteId/revoke |
Stop a live code working |
DELETE |
/workspaces/:id/invitations/:inviteId |
Delete the invitation record |
Responses are enveloped as { "data": ... }, and failures as
{ "error": { "code", "message", "requestId" } } with a stable machine-readable code.
| Layer | Choice | Reasoning |
|---|---|---|
| API | Hono on Cloudflare Workers | One Worker serves API and SPA from a single origin |
| Database | Supabase Postgres | Row-level security is a real authorization boundary |
| Cache | Upstash Redis | Analytics cache only, Postgres stays authoritative |
| Storage | Cloudinary | Direct browser uploads with server-signed policies |
| UI | React 19, Vite, Tailwind CSS v4 | Strict types, fast builds, route-level code splitting |
| Components | shadcn/ui on Radix | Accessible primitives rather than reinvented ones |
| Server state | TanStack Query | Workspace-scoped cache keys prevent cross-tenant bleed |
| Forms | React Hook Form with Zod | The same schemas back client and server validation |
| Charts | Recharts | Theme-aware and accessible |
Money is never optimistic. Preferences and toggles update optimistically. Creating or deleting a transaction waits for the server, because a row that appears in a ledger and then vanishes is worse than a brief spinner.
Totals are computed in Postgres. The client sends a period, never a figure. The dashboard_stats
function returns both the requested window and the preceding window of equal length, so
period-over-period deltas are server-derived too.
"Today" belongs to the user, not to UTC. Relative ranges resolve against the caller's local date. Without that, someone in IST recording a transaction just after midnight would find it excluded from "last 30 days", because the server's day had not rolled over yet.
Cache invalidation by version bump. Upstash has no cheap wildcard delete, so every analytics key embeds a per-workspace counter. A mutation increments the counter and orphans that whole generation at once, and the orphans expire on their own TTL. Every Redis call fails open, because a cache outage must not take the ledger down with it.
Chart colours were validated, not chosen by eye. Received against spent is a two-slot categorical encoding. The obvious green and red pairing failed colourblind separation under deuteranopia, precisely the pair a significant share of men cannot resolve, on a chart whose entire job is showing money in against money out. Teal and orange clear the check against both light and dark surfaces, and identity never rests on colour alone, since both charts carry a legend and a naming tooltip.
npm test # both packages
npm run typecheck # strict TypeScript across bothCoverage targets the logic that is easy to get quietly wrong: inclusive date windows, leap years, the timezone anchor, two-decimal money handling, the extension against MIME cross-check that stops a renamed executable, and the storage URL construction that has already broken downloads once.
Two GitHub Actions workflows drive releases.
flowchart LR
P["Push to main"] --> CI["CI<br/>typecheck, tests, secret scan"]
P --> G{"Touches backend<br/>or frontend?"}
G -->|no| STOP["No release"]
G -->|yes| V["Verify<br/>typecheck and tests"]
V --> BLD["Build SPA"]
BLD --> DEP["wrangler deploy"]
DEP --> SMK["Smoke test<br/>health, auth guard, 404, SPA"]
ci.yml runs on every push and pull request.
deploy.yml runs only when application code changes, so a
documentation commit never ships a release.
Four repository secrets are required under Settings, Secrets and variables, Actions:
| Secret | Purpose |
|---|---|
CLOUDFLARE_API_TOKEN |
Deploy the Worker |
CLOUDFLARE_ACCOUNT_ID |
Target account |
VITE_SUPABASE_URL |
Inlined into the bundle at build time |
VITE_SUPABASE_ANON_KEY |
Inlined into the bundle at build time |
Server-side secrets are deliberately not passed through CI. They are set once with
wrangler secret bulk and persist across deployments, so the pipeline never sees them.
To deploy by hand:
npm run build
npm run deployA few settings live in provider consoles rather than in this repository.
- Supabase Auth. Set the site URL and redirect allow list to your deployed origin. For Google
sign-in, enable the provider and register
https://<project-ref>.supabase.co/auth/v1/callbackwith your Google OAuth client. Identity linking requires manual linking to be enabled. - Cloudinary. PDF and ZIP delivery is disabled by default on new accounts as an anti-abuse
measure, and every such URL returns
401until it is enabled under Settings, Security. If Strict Transformations is enabled, setCLOUDINARY_ENABLE_TRANSFORMS=falseso the app serves plain delivery URLs instead of derived ones.
Contributions are welcome. Open an issue to discuss anything substantial before writing code.
- Fork the repository and create a branch from
main. - Run
npm run install:all, thennpm testandnpm run typecheckbefore opening a pull request. - Keep commits focused, and explain the reasoning in the commit body rather than only the change.
The CI workflow runs the same checks, so a green local run usually means a green pull request.
Released under the MIT License.
The interface is adapted from shadcn-admin by Sat Naing and builds on shadcn/ui, both MIT licensed.

