NEXUS
Product Overview

What is MiStorage?

MiStorage is a self-storage facility management platform: an HQ console for the operator, a day-to-day console for each location's staff, and a public storefront where renters browse units, sign up and pay. The backend is a REST API built on ASP.NET Core (.NET 10), Entity Framework Core and PostgreSQL, with JWT authentication.

Who uses it

Three audiences, three token types, three front doors. Every request is scoped to one of them.

HQ (system users)The operator's owners and administrators behind /super-admin: locations, staff, roles, protection plans, notifications, tasks and settings.
Location staff (tenant users)The staff console for one location behind /tenant/{locationId}: units, renters, leases, notifications and the location's own settings.
RentersStorage customers on the public storefront at /s/{subdomain}: browse units, get a quote, rent online, then manage leases, invoices and addresses.

Renter accounts are scoped per location: the same person legitimately holds separate accounts at two locations, so the unique key is CustomerId + Email, not Email alone.

Which product is this?

MiStorage ships in two shapes, and one setting decides which.

It was built as a multi-tenant SaaS platform selling subscriptions to storage companies. Since 2026-09-10 that is not the product: the client is the storage operator. They own the units, nobody bills them, and rent settles to their own merchant. The SaaS layer is archived rather than deleted — it still compiles and is still covered by tests, but every one of its endpoints answers 404 while the flag is off.

SaaS (archived)Single operator (current)
Who runs /super-adminMiStorage, the platformThe client, at owner / HQ level
Who runs /tenant/{id}A subscribing storage companyThe client, at location level
What a Customer row isA company paying a subscriptionA location
Who signs up at /tenant/registerAny company, self-service, with a cardNobody — staff accounts are created by an administrator
Where renter rent landsThe company's merchant, else the platform'sThe client's own merchant
Subscriptions, plans, platform invoicesThe business modelArchived behind the flag

Tech stack

AreaChoice
Runtime.NET 10 / ASP.NET Core Web API (controllers)
ORMEntity Framework Core 10
DatabasePostgreSQL 17 (Npgsql provider)
AuthJWT bearer access tokens (15 min, held in memory) + hashed, rotating refresh tokens in an HTTP-only cookie
Password hashingBCrypt, work factor 12
Field encryptionAES-256-GCM — the renter's Social Security Number, and nothing else
PaymentsPhoenixGate / QuickPayments, tokenized browser-direct — no card number ever reaches the API
API referenceOpenAPI + Scalar, mounted in Development only
TestsxUnit — 249 tests, all passing as of 2026-10-06
Frontend (MiStorage)
AreaChoice
FrameworkNext.js 16 (App Router), React 19, TypeScript
UITailwind CSS 3, Radix primitives with shadcn-style components, lucide and Tabler icons, framer-motion
State and formsZustand 5 stores; react-hook-form with zod
Charts and editorschart.js (lazy-loaded); Tiptap and Editor.js
Talking to the APIAuth calls through same-origin relay routes (app/api/{auth,tenant-auth,renter-auth}); everything else as bearer calls straight to the API
PaymentsNo SDK: the browser posts the card to the QuickPayments token endpoint and keeps only the token, brand and last four
TestsNone — tsc, eslint and next build are the safety net
Where it runs
PieceRuns on
API (mistorage-backend)Azure App Service, West US 3 — published by hand; there is no CI
Frontend (MiStorage, Next.js)AWS Amplify, built automatically from main a few minutes after a push
DatabaseAWS RDS, PostgreSQL 17

How a request is handled

Middleware order in Program.cs: security headers → global exception handler → rate limiting → HSTS / HTTPS redirect (production) → CORS → authentication → SaaS feature gate → authorization → controllers. Request bodies are capped at 10 MB. GET /api/health is mapped outside the controllers: anonymous, unthrottled, and not wrapped in the response envelope.

  • CORS allows the configured frontend origins with credentials.
  • Rate limiting is per IP, in buckets grouped by surface so one audience can never exhaust another's allowance. Refresh is unmetered on purpose — it is cookie-authenticated and runs on a timer.
  • The SaaS gate runs before authorization, so an archived endpoint answers 404 to everyone rather than 401 or 400, which would advertise that it exists.
  • Authorization uses three policies keyed off a userType claim stamped at mint time: SystemUser, TenantUser and Renter. Each audience has its own refresh-token table, so a token minted for one can never be presented against another.

Refresh tokens are hashed at rest and rotated on every use. A rotated token presented again outside a short grace window is treated as theft and revokes the whole family. See the APIs page for the session policy and the full endpoint reference.

Core domains

Auth & RBACUsers, roles, permissions, refresh and password-reset tokens — separately for HQ, location staff and renters.
LocationsThe Customer entity, now meaning a location: profile, subdomain, status, bans, contact details and staff logins.
Storefront & rental flowPublic unit listings with a size guide, quotes, the three-step checkout, leases and renter invoices.
Units & leasesStorage units with a size taxonomy, climate control, rates and out-of-service state; leases with move-out notice and staff move-out.
NotificationsTemplates, recipient selection, previews, batches, history and replies — for HQ and for each location.
Protection plans, tasks, settings, auditRenter protection plans, HQ task board, system settings, and audit logs for sensitive actions.

What a rental application asks for (Social Security Number, driver's licence) is resolved by ApplicationFieldPolicyService from three levels — the unit, then its location, then the installation default. null means "no opinion, ask the level above"; false means "do not ask". Both fields are collected by default.

HQ and its locations

HQ and location staff are two separate identities with two front doors. Since 2026-09-22 HQ can walk through the second one without a second password.

  • Manage Facility on an HQ location page signs the administrator into that location's console as its Owner and lands on /tenant/{id}/dashboard. The button is disabled unless the location is active. The exchange is audited (Location.Entered, naming the HQ user); after that the session is an ordinary Owner session.
  • Back to HQ appears in the console's user menu while the HQ session is still live in that tab. It disappears after a hard reload; the HQ URL still works.
  • The Staff tab on a location page lists its logins — role, status, last seen, created — and lets HQ add a Manager or Staff login, set a password, or deactivate and reactivate one. Every location has one Owner, seeded when the location is created; the Owner can be given a new password but not deactivated.
  • Setting a password or deactivating a login signs that person out everywhere. There are no invitations or emailed resets yet, because email does not deliver.
  • Manager and Staff see the whole location console today: tenant RBAC is not built.

Guided tour. Both consoles open with a step-by-step tour the first time each user signs in on a browser: 27 steps for HQ (dashboard, locations, Manage Facility, staff, users and roles, protection plans, notifications, tasks, settings) and 21 for a location (dashboard, units, renters, leases, notifications, settings). It moves between pages by itself, works from the keyboard (← →, Esc to skip), and falls back to a centred card when a target is hidden, as on a phone. Progress lives in the browser's localStorage (mistorage.tutorial.{hq|location}.{userId}); the Start the tutorial card under Settings replays it.

Renter applications. Staff can open a renter's rental application from the Renters page (click the name, or View Application): contact details, driver's licence, emergency and alternate contacts, employer, how they heard about the location, texting consent and account status. The Social Security Number is never sent to the browser — only whether one is on file.

Where the project stands

Work was planned as numbered phases in the customer-portal gap analysis; the pivot to single operator and its follow-ups sit outside that numbering.

PhaseState
0 — compliance / raw-PAN removalDone. Credential rotation still owed.
1 — unit size taxonomyDone (2026-08-05)
2 — public storefront API + UIDone, live-verified (2026-08-10 / 08-12)
3 — renter identityDone, live-verified (2026-08-21)
3.5 — password recovery (unplanned)Done (2026-08-25). None of the three audiences had a reset path.
4 — rental flow + renter billingDone end to end (backend 08-25, frontend 09-01). The real card charge has never run.
5 — renter portal + tenant RBACRenter half substantially done (09-08). Tenant RBAC not started.
6 — DNS automation, proration, occupancy reportingNot started
Single-operator pivotDone (2026-09-10). SaaS archived behind Saas:Enabled.
Single-operator follow-upsDone (2026-09-11): Locations rename, editable units, per-unit / per-location application fields.
Production catch-up, one owner many locationsDone (2026-09-15)
HQ ↔ location bridgeDone (2026-09-17), deployed 2026-09-22
Guided tour for both consolesDone, deployed (2026-09-25)
Renter application view; health checksDone, deployed (2026-10-06)

Decisions made on purpose

So nobody spends time re-deriving them.

  • The storefront is server-rendered and the rest of the app is not. A shop window has to be indexable and fast on a phone.
  • `/s/{subdomain}` instead of real subdomains. A stand-in until DNS automation (Phase 6); routing already keys off the subdomain segment.
  • Move-out is notice, not cancellation. A renter gives notice; the lease stays active and rent keeps accruing until staff end it.
  • Registration returns one identical acknowledgement for every outcome when email verification is on. That is an anti-enumeration measure, not a missing branch.
  • Signing in does not end a renter's other sessions. POST /api/renter-auth/revoke-all is the deliberate way to end all of them.
  • The API does not migrate on startup. Production schema changes are applied by hand from an audited, idempotent script.