A tenant looking at an appointment and an operations employee rescheduling it need different interfaces to the same record. If each interface keeps its own copy of the appointment, the product has a reconciliation problem before it has solved the scheduling problem.

Rental OS puts five portals over one application model. Next.js serves pages, server-rendered entry points and browser components. Convex holds the domain records and runs queries, mutations, actions and scheduled jobs. WorkOS AuthKit establishes who is signed in. The interesting decisions are about what crosses those boundaries: an access token, a reference to an existing record, a requested state transition, or the result of an external call.

The source is public under the MIT licence, so this article names real modules and functions. My work spans product design and application engineering: the participants’ workflows, the domain and permission rules, and the integration of interface, backend and deployment. The recorded work period is July to August 2026; the code described here is main as of September 23, including the September login, authorization and deal-room fixes. The imported Git history does not date individual subsystems. Preparation, verification and the September fixes used AI assistance, and Next.js, React, Convex, WorkOS and the interface libraries supply a great deal of upstream machinery.

Explanatory architecture diagram showing five Rental OS portals, WorkOS authentication, authenticated Convex queries and mutations, shared domain records, and asynchronous actions.

Explanatory architecture diagram; it is not a product screenshot or a network inventory.

Open full-size image

One application, several views

The portal split follows how people work. Guards need a focused phone interface. Administrators need wide queues and controls. Tenants and owners need only the parts of a rental that concern them. Next.js route groups, (guard), (ops), (admin), (tenant) and (owner), organise those experiences, and each group’s server layout checks admission before rendering.

Underneath, convex/schema.ts defines one model with typed document references: users, roles, user_role_assignments, owners, leads, listings, tenant_inquiries, visits, negotiations, chat_channels, rental_transactions, closures, payouts and more. A visit refers to its tenant inquiry through tenant_inquiry_id. The inquiry does not maintain its own copy of the visit’s facts. That gives the backend a relationship to follow when one workflow changes another.

Cancellation is a concrete example. A trigger registered on the visits table in convex/functions.ts watches for a visit moving to CANCELLED or NO_SHOW. If the linked inquiry is still VISIT_SCHEDULED, the trigger moves it back to GUARD_ACCEPTED and clears the matching visit reference, ready for another attempt. The correction sits beside the write, so it applies whichever portal made the change.

Shared records make these relationships easy to express, and they concentrate responsibility. A page can look correct while an inconsistent transition sits behind it. The backend has to validate the operation and keep the links consistent; reactive rendering cannot repair a bad write.

Authentication reaches the query boundary

In the browser, ConvexClientProvider wraps the app in Convex’s ConvexProviderWithAuth with a small adapter, useAuthFromAuthKit, that fetches and refreshes the AuthKit access token. On the backend, convex/auth.config.ts trusts WorkOS as a custom JWT issuer: RS256 tokens verified against WorkOS’s published signing keys. Every Convex function can then ask ctx.auth.getUserIdentity().

The identity is only a subject string. getAuthenticatedUser in convex/auth.helpers.ts resolves it to an application user through the users.by_workos_user_id index. That local record is the bridge from “WorkOS says this is user X” to “what may user X do here?” The helpers then layer checks:

// convex/auth.helpers.ts — requirePermission (abridged)
const user = await requireAuth(ctx);              // signed in, not banned
if (!hasPersona(user, "ADMIN") && !hasPersona(user, "OPS")) throw …;
if (user.status !== "ACTIVE") throw new Error("Account not active");
// union the permissions of every non-deleted role assigned to this user
if (!permissions.has(permission)) throw new Error(`Missing permission: ${permission}`);

lib/constants.ts defines 115 permission strings, from societies.view to system.configure. Roles are named bundles of them, stored as data in roles and assigned through user_role_assignments.

The September login work carried the requested portal through the hosted sign-in round trip. The callback accepts only the five known portal values, and users.resolvePostAuthDestination checks the requested portal against the account’s memberships. An operations account asking for the admin portal is let into the permission-filtered backoffice; a tenant asking for it is sent back with role_mismatch.

There is a deliberate limit here. The core helpers check membership, the user_types array, not the active_persona field that records which portal someone is currently using. The active persona controls the experience; backend checks decide each operation. Treating a persona switch as a security context switch would promise a stronger boundary than the code provides. Elsewhere the active persona labels a message or narrows a list behind a permission check that already ran. The one place it widened access, deal-room chat, was a bug the sweep below caught.

Authorization, swept before the source went public

Publishing the source changes the threat model in one specific way: everyone can read every function’s argument list. Before the repository went public, every client-callable entry point was reviewed: each exported query, mutation and action in convex/, plus each HTTP route. The record is docs/security/authz-sweep-2026-09-23.md in the repository.

The method was deliberately mechanical. The function list came from the source itself, so nothing depended on memory of which endpoints mattered. For each function body, and any local helper it calls, three questions:

  1. Is the caller authenticated?
  2. Is the gate a role permission (requirePermission or requireAnyPermission), a persona check for a self-only record, or an ownership or participant check?
  3. Does the check run before any data access?

Permission-gated functions were also checked for fit: reads need a VIEW permission, writes a MANAGE or EDIT permission. Public functions were checked for exactly which fields they return and whether they are rate-limited.

Result Functions
Public by design: public-safe fields, rate-limited writes 10
Authenticated and correctly scoped 490 (17 with follow-up notes)
Gaps fixed, function still public but now gated or trimmed 26
Gaps fixed in the deal room, in a follow-up change 7
Gaps fixed by making the function internal 3

That is 536 exported functions before, 533 after. The gaps fell into a handful of recognisable classes:

  • Entry points that should never have been client-callable. A data-migration action with run and rollback had no login check and returned migration plans containing user ids; nothing in the web app called it. It became an internalAction. The support-inquiry submit mutation let callers bypass the contact-form route and its shared-secret check; it became internal, and the HTTP route calls it through internal.
  • Trusting an identifier from the client. actions/workos.resetGuardPassword reset the password of whichever WorkOS account the client named. It now looks up the WorkOS id of the guard being reset and refuses a mismatch.
  • Minting privilege. roles.create accepted is_system_role from the client, which produced a role that cannot be renamed or deleted. The argument is gone; system roles come only from the seed. Creating an operations account needed only guards.create; it now also needs roles.manage, since the account receives a role.
  • Persona gates where a permission was meant. The field-worker rollout controls checked only for an admin membership; they now require system.configure.
  • Reads that returned too much. A tenant’s view of a rental transaction included the owner’s contact record, the closure’s commission and internal notes. A guard’s bounty list included the tenant’s identity. An owner’s relationship-manager view included that employee’s internal performance data. A public referral-code lookup returned the referrer’s name and internal ids. Each now returns a trimmed shape built for its screen; the staff views are unchanged.
  • Check ordering. opsManagement.acknowledgeWarning read the warning before checking login, so an anonymous caller could learn whether an id existed. Login now comes first.
  • Access keyed on the active persona. Deal-room chat decided room visibility from active_persona. Inside the deal room covers the fix, which derives the caller’s role from the inquiry and listing records.
Admin roles page listing two system roles: Ops Agent with 43 permissions and Super Admin with 115.

Hosted demo, synthetic seed data, captured 2026-09-23. Both roles come from the seed and carry the System badge; Super Admin holds all 115 permissions defined in lib/constants.ts. After the sweep, roles.create no longer accepts is_system_role, so a client cannot create another undeletable role.

Open full-size image

Every fix has a regression test, and the tests had to fail on the old code to count. convex/authzHardening.test.ts and convex/authzHardening.workos.test.ts add 25 tests, with the WorkOS client mocked so nothing touches the network. I ran them against the pre-sweep revision: 21 fail and 4 pass. The four that pass are the positive controls, such as “the matching guard’s password can still be reset”, which prove that each fix narrows access without breaking the legitimate path. The deal-room change adds 12 tests in convex/dealRoomAuthz.test.ts; 7 fail against the code before that fix. On main, the whole suite runs 382 passing tests with one skipped.

The sweep also lists open items that need a product decision rather than a patch: whether public listing pages should show exact flat numbers, how the owner “earnings” view should be modelled, some rate limits and webhook secret checks that should be tightened, and a set of separation-of-duties notes where one permission holder can act on their own record. They are written down in the same file so they stay visible.

What the sweep is not: a penetration test, a formal verification, or proof that no gap remains. It is a complete, source-derived reading of the public surface with a failing-first test for every fix. That is a concrete floor for a codebase that anyone can now read.

Reactive reads reduce coordination in the browser

Chat shows the query path. ChatView subscribes to channel details, unread counts and recent messages with Convex useQuery; MessageList uses usePaginatedQuery; ChatInput calls the chatMessages.send mutation. Convex keeps those subscriptions current as the underlying documents change, so there is no separate polling or message-distribution layer to maintain for these screens.

Tenant messages page listing deal-room conversations with status badges, unread counts and a live-updates indicator.

Hosted demo, synthetic seed data, captured 2026-09-23. The list comes from a paginated Convex query, chatChannels.listMyChannels. The “Live updates connected” label reflects the browser's online state (navigator.onLine), not the Convex connection itself. The [seed-neg] markers are the seed data's own labels.

Open full-size image

Reactivity does not decide what a query may expose. A subscription is a continuing read surface, so it needs the same field-level discipline as a one-off request. Several of the over-broad reads in the sweep were reactive queries, and faster propagation would only have made them visible faster.

I have no load-test result, so I make no claims about concurrent users or delivery latency. The evidence is the programming model and the deployed portal journeys.

Durable writes leave operational evidence

Every backend module that writes to the database, apart from one demo cleanup mutation, imports mutation and internalMutation from convex/functions.ts rather than from Convex’s generated server. Those builders wrap the database handle with convex-helpers triggers, and registerAuditTrigger attaches an audit trigger to each of the 76 tables listed in AUDITED_TABLES. Every insert, update or delete on those tables writes an audit_logs row with the actor, an action such as LEADS_UPDATE, the entity reference, and a field-level diff. Keeping this at the database-wrapper boundary means no form component has to remember to write an audit entry.

Admin audit log showing rows with actor System, actions such as NOTIFICATIONS Created and OWNER_RM_ASSIGNMENTS Updated, and entity references.

Hosted demo, synthetic seed data, captured 2026-09-23. Rows attributed to System come from writes with no authenticated identity, such as seeding and scheduled jobs; resolveActor records SYSTEM in that case. The page heading says “full history of all platform actions”; precisely, it covers writes to the 76 audited tables made through the wrapped mutation builders.

Open full-size image

The same layer keeps three @convex-dev/aggregate components current: lead counts, visit counts and payout totals. A payout is a recorded payment owed to a worker, such as a hypothetical $10 visit reward. Summing payout records does not verify that any bank transfer happened. Daily analytics snapshots and operational checks run separately as scheduled jobs from convex/crons.ts.

These records answer different questions. An aggregate answers a summary question cheaply. An audit row explains one change. Neither amounts to event sourcing or a tamper-proof history: the audit covers a defined table list, and a demo cleanup mutation deliberately uses the raw builder.

External work has a separate lifecycle

Calls to outside services run in Convex actions, and mutations record the durable state around them. Chat batching illustrates the split. chatBatching.queueMessage records a message and schedules processBatch, which schedules processWithAI as an action. deliverBatch and failBatch record the result, and a cron runs recoverStaleBatches for batches stuck mid-flight.

The notification subsystem records events and per-channel state first. It applies recipient preferences and deduplication, retries with exponential delay, and moves exhausted work to a dead-letter state. Recovery of stale processing is a scheduled job rather than something a page has to remember to trigger.

That machinery is real, but whether an integration is complete depends on the action at the end of it. The push, WhatsApp, SMS and email adapters in convex/actions/notifications.ts check configuration and record a synthetic delivery; they call no provider. The identity-verification actions in actions/kyc.ts and the e-signature actions in actions/esign.ts return canned results after a short delay. No code creates a payment order, and the payment webhook verifies signatures but only logs the event. A successful-looking record from any of these paths is not evidence that a person was notified, verified, signed or paid.

Two actions do call a provider when configured: actions/transcription.transcribeAudio sends a stored recording to OpenAI, and actions/dealTermExtraction.extractTerms requests structured output and maps terms back to message ids. The sweep moved the field-worker check for transcription in front of the paid call. I did not invoke either service for this article, so their presence supports an implementation claim, not an accuracy claim.

Deployment changes more than a URL

The supported deployment is one stateless Next.js container, built as a standalone server running as a non-root user, talking to external Convex and WorkOS services. /healthz reports whether the web process is up. Authenticated portal journeys exercise far more of the system than that probe does.

The September login release kept the backend and web revisions explicit. The backend deployed first, because the new frontend passes an optional intended_persona argument that only the new backend’s argument validator accepts; older callers keep working because the argument is optional. The web image followed. No schema migration or demo reseed was needed.

Production verification caught what entry-page checks missed. Sign-out cleared the session cookies, then redirected to /, which by then itself redirected to /homepage. The client expected a Server Action redirect response, received an ordinary HTML document, and showed its error boundary. The fix sends sign-out straight to /homepage, and scripts/check-signout-redirect.mjs reproduces the original failure against a production build on loopback. The public summary is in the repository under docs/releases/login-split-2026-09-22/.

That record covers the five fixture sign-ins and the tenant-to-admin refusal. It does not prove every data-changing journey or production readiness. The hosted demo is shared synthetic state, not a per-visitor sandbox, which is why access is arranged on request.

Reading the source

The repository’s README has a zero-signup mode: LOCAL_AUTH=true npm run dev starts a local Convex backend with a locally signed JWT issuer, and LOCAL_AUTH=true npm run seed loads the synthetic fixtures, with no WorkOS or Convex account required. SECURITY.md explains how to report a vulnerability privately.

If you want the boundaries in the order this article presents them, read convex/auth.helpers.ts, then convex/functions.ts, then lib/negotiation.ts with convex/negotiationProposals.ts, then the sweep record. The simulated adapters are labelled as such in the README, and the prototype status is not a figure of speech: identity checks, e-signatures, notifications and payments are simulated or record-only.

Read the source on GitHub, or request demo access for a guided look at a particular portal.