Back to all projectsProduct / Solo Build

Rynto

Solo build, in production

A live property management app in daily production, designed and shipped solo. A double-entry ledger tracks balances, with tenant KYC verification and row-level-security private storage.

Expo / React NativeTypeScriptSupabasePostgreSQL

How it works

01·Tenant Action02·Ledger Engine03·KYC Verification04·RLS Storage05·Balance

The problem

Small property owners run their buildings over WhatsApp threads and spreadsheets: who owes what, whose KYC is on file, who moved out last month. None of that scales past a handful of units, and none of it gives a tenant a real way to see their own balance or pay rent without asking. I designed and built Rynto solo to replace that with a real app: owners manage properties, houses, and tenants; tenants see their balance and pay rent; every bill, payment, and KYC document has an actual record behind it instead of a chat history.

Architecture

  • It's a three-tier structure: the Expo/React Native UI calls thin api/ wrappers, which call a repository layer that's the only code in the app allowed to talk to Supabase directly, which talks to Postgres, Row-Level Security, Storage, and Edge Functions. There's no custom backend server. Supabase is the entire backend.
  • Global state runs on Legend State, hydrated once at launch, alongside eight React Contexts for feature-scoped state like auth, permissions, and notifications.
  • Eight Edge Functions carry the async and scheduled work: generating invites, generating rent bills, delivering push notifications, sending reminders on a cron, account deletion, and a couple of reporting/webhook functions.
  • Every database change is a tracked, timestamped migration file applied through the Supabase CLI. Nothing gets hand-edited on the live database.
  • Transactions are an append-only log, never updated or deleted. A Postgres function recomputes a house's running balance from that log whenever a bill or payment changes.
Rynto system architecture

Design decisions

KYC documents as append-only history

I treat every KYC upload as a new row instead of overwriting the last one, then resolve which one is currently valid at read time. A tenant re-uploading a document while an earlier one is still under review doesn't hide the already-approved version behind a pending one.

Security-definer RPCs to break RLS recursion

The first cross-table RLS policy I wrote checked houses from a properties policy and properties from a houses policy, and Postgres re-evaluated both recursively until access to both tables broke entirely. I fixed it by wrapping the check in a security-definer function, then made that the standing pattern for every cross-table RLS check since, instead of solving it once and moving on.

Fire-and-forget push delivery

A bill or house mutation writes its notification row synchronously, then invokes the push-delivery function without waiting on it. A slow or failing push provider never blocks the action the user is actually waiting on.

An append-only ledger instead of a literal double-entry one

Transactions are only ever inserted, never updated or deleted, and a house's balance is a running total recomputed from that log by a Postgres function whenever a bill or payment changes. That gives me an auditable history and a balance I can always rebuild from source, without maintaining matched debit and credit entries for a level of bookkeeping formality this app doesn't need yet.

Fixing storage before finishing the RLS pass

I'd planned to work through tables in a set order during a security audit, but recon showed the KYC document bucket was open to anyone with no ownership check. I patched that first as an emergency fix, out of the planned order, then continued the rest of the pass on schedule.

Security

  • Every sensitive table has Row-Level Security enabled, scoped by ownership between owner, tenant, and house or property, verified table by table in a documented audit rather than assumed from the migration history.
  • Role changes go through a security-definer RPC scoped to the caller's own account, with a trigger blocking any direct update to a profile's role column. Nobody can promote themselves by writing to the row directly.
  • KYC documents and rental agreements are served through signed URLs from private storage buckets, not public bucket URLs, with ownership-scoped storage policies.
  • Phone sign-in uses Supabase's own OTP flow end to end today. My own project docs still describe this as a stubbed, hardcoded test path, which was true at some point but isn't what the current code does. I need to go back and update that.

Constraints & tradeoffs

Most tables shipped with RLS off

A pre-release security audit found that core tables (profiles, properties, houses, bills, transactions, roles) had Row-Level Security disabled, with Supabase's default anon grant still active. The anon key ships in every app build, so this wasn't a logged-in user having too much access, it was open read and write on the tables that mattered most. I enabled RLS across every sensitive table and locked storage down to ownership-scoped policies.

A public storage bucket held KYC documents

The KYC bucket had storage policies granting anyone access with no ownership check, and the bucket itself was set public, which bypasses storage RLS for reads entirely. That was the single most severe finding in the audit and the first thing I fixed.

The tenant invite flow trusted the request, not the requester

The original invite acceptance ran as a public, unauthenticated function that assigned a tenant to a house on a bare request, trusting whatever phone number was encoded in the token rather than checking who was actually making the request. It also had no guard against two people accepting the same invite at once. I rebuilt it around a tracked invites table, a deep link into a native in-app screen, and a security-definer function that requires the caller to be authenticated and phone-matched before the assignment happens, with an atomic guard closing the race.

Every Postgres function was callable by anon by default

Independent of what any individual migration granted, Supabase's schema-level default lets the anon role execute every function unless a migration explicitly revokes it. I fixed this function by function as I found them. A broader platform-level default fix is still on my list, not done yet.

The UI still shows actions RLS already blocks

Tenants still see add-bill and delete-bill buttons that RLS now correctly rejects server-side. It's not a security hole anymore, just an interface that hasn't caught up to the access model yet.

Deployment

  • Builds go through EAS with five profiles: a preview APK, a Gradle preview release, two internal-distribution dev builds, and the production store build. A GitHub Actions workflow builds a preview APK on demand.
  • Every database change is a tracked, timestamped migration file applied through the Supabase CLI, with a documented rollback path.
  • There's no custom backend to deploy. Supabase is the entire server side: Postgres, the Edge Functions, Storage, and Auth.

Current state

Rynto is live in production, in daily use by real property owners and tenants, at version 1.0.0 across both stores. It's been through a real security audit and a full invite-flow rework since it first shipped, and I'm still actively maintaining it, not treating it as done.