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.
Three audiences, three token types, three front doors. Every request is scoped to one of them.
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.
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-admin | MiStorage, the platform | The client, at owner / HQ level |
| Who runs /tenant/{id} | A subscribing storage company | The client, at location level |
| What a Customer row is | A company paying a subscription | A location |
| Who signs up at /tenant/register | Any company, self-service, with a card | Nobody — staff accounts are created by an administrator |
| Where renter rent lands | The company's merchant, else the platform's | The client's own merchant |
| Subscriptions, plans, platform invoices | The business model | Archived behind the flag |
Saas:Enabled on the API (default false) is mirrored by NEXT_PUBLIC_SAAS_ENABLED in the frontend. The frontend value is baked in at build time, so changing it needs a rebuild. A mismatch is the worst outcome: a UI offering subscription plans that the server answers 404 for.
| Area | Choice |
|---|---|
| Runtime | .NET 10 / ASP.NET Core Web API (controllers) |
| ORM | Entity Framework Core 10 |
| Database | PostgreSQL 17 (Npgsql provider) |
| Auth | JWT bearer access tokens (15 min, held in memory) + hashed, rotating refresh tokens in an HTTP-only cookie |
| Password hashing | BCrypt, work factor 12 |
| Field encryption | AES-256-GCM — the renter's Social Security Number, and nothing else |
| Payments | PhoenixGate / QuickPayments, tokenized browser-direct — no card number ever reaches the API |
| API reference | OpenAPI + Scalar, mounted in Development only |
| Tests | xUnit — 249 tests, all passing as of 2026-10-06 |
| Area | Choice |
|---|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript |
| UI | Tailwind CSS 3, Radix primitives with shadcn-style components, lucide and Tabler icons, framer-motion |
| State and forms | Zustand 5 stores; react-hook-form with zod |
| Charts and editors | chart.js (lazy-loaded); Tiptap and Editor.js |
| Talking to the API | Auth calls through same-origin relay routes (app/api/{auth,tenant-auth,renter-auth}); everything else as bearer calls straight to the API |
| Payments | No SDK: the browser posts the card to the QuickPayments token endpoint and keeps only the token, brand and last four |
| Tests | None — tsc, eslint and next build are the safety net |
| Piece | Runs 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 |
| Database | AWS RDS, PostgreSQL 17 |
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.
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.
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 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.
/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.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.
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.
| Phase | State |
|---|---|
| 0 — compliance / raw-PAN removal | Done. Credential rotation still owed. |
| 1 — unit size taxonomy | Done (2026-08-05) |
| 2 — public storefront API + UI | Done, live-verified (2026-08-10 / 08-12) |
| 3 — renter identity | Done, 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 billing | Done end to end (backend 08-25, frontend 09-01). The real card charge has never run. |
| 5 — renter portal + tenant RBAC | Renter half substantially done (09-08). Tenant RBAC not started. |
| 6 — DNS automation, proration, occupancy reporting | Not started |
| Single-operator pivot | Done (2026-09-10). SaaS archived behind Saas:Enabled. |
| Single-operator follow-ups | Done (2026-09-11): Locations rename, editable units, per-unit / per-location application fields. |
| Production catch-up, one owner many locations | Done (2026-09-15) |
| HQ ↔ location bridge | Done (2026-09-17), deployed 2026-09-22 |
| Guided tour for both consoles | Done, deployed (2026-09-25) |
| Renter application view; health checks | Done, deployed (2026-10-06) |
Email is currently undeliverable (the SMTP provider rejects the configured sender), so verification links, password resets and notifications reach nobody — email verification is switched off as a consequence. The payment gateway sandbox is only reachable from the US, so the real card charge has never been exercised. Proration of the first month is a commercial decision still owed by the client. Application Insights receives nothing from the API, so the platform's event log and the two /api/health endpoints are the only production signals.
So nobody spends time re-deriving them.
POST /api/renter-auth/revoke-all is the deliberate way to end all of them.