What HQLead is
Every article in this series mentions HQLead without explaining it, which is a fair complaint. So: HQLead is the platform operator. It's the account that exists because somebody has to run PropertyOS itself — register a landlord, provision that landlord's admin, unlock an account after someone forgets their password, deactivate a landlord who stops paying.
It is not a landlord with extra permissions. It is not an admin role. It's a separate kind of actor, in a separate module, and the reason is worth more than the definition.
The shape most systems take
The obvious design is one users table and a role column. One login
endpoint, one session store, one token, and a check somewhere that reads
if (user.role === "admin").
That works until the day it doesn't, and the day it doesn't is expensive. A bug in the role check isn't "a customer sees the wrong page" — it's "a customer sees every other customer." The blast radius of a single mistake is the entire platform, because there is only one mechanism, and everything is downstream of it.
What's actually separate
PropertyOS splits them at every layer that could leak:
Separate tables. hq_leads has its own row shape, its own password
hash, its own lock state. A landlord admin lives in landlord_admin with
credentials in landlord_admin_credentials. No join connects an operator
to a tenant identity, because there is no relationship to model.
Separate signing secrets. HQLEAD_JWT_SECRET and
LANDLORD_JWT_SECRET are different values. This is the one that matters
most: a landlord admin's token is not merely rejected by operator
routes, it is unverifiable by them. Forging the claims gets you
nothing, because the signature was made with a key those routes never
use. A role-column design cannot offer that — the same key signs
everything, so the only thing standing between a user and the admin area
is a comparison.
Separate session namespaces. hqlead:session:<id> and
session:landlord_admin:<id> are different Redis keys. Revoking one has
no effect on the other, and neither can be mistaken for the other by a
lookup.
Separate middleware. verifyHQLeadJWT, verifyHQLeadCSRF and
verifyHQLeadRole are their own files, not parameters passed to a shared
guard. A change to the landlord admin chain cannot alter operator
behaviour by accident, because they share no code.
Separate rate limits. Someone attacking the operator login cannot consume the budget that keeps tenant logins working.
The one place they deliberately meet
Having said all that, there is exactly one function where both actor types appear, and it's the function that decides what data you can see:
export async function resolveLandlordIdForActor(actor, { landlordadminIdentityRepo }) {
if (actor.type === "hqlead") return null;
if (actor.type === "landlord_admin") {
const identity = await landlordadminIdentityRepo.findById(actor.adminId);
if (!identity) throw new AppError("Landlord admin not found", 404);
return identity.landlord_id;
}
throw new AppError("Invalid actor", 403);
}
null means unrestricted. That is the operator's cross-tenant view,
written down once, in the same place as the restriction it is an
exception to.
This is deliberate, and it's the opposite of the rest of the design. Had I given the operator its own query path, the two would drift: someone adds a filter to the tenant-scoped version, forgets the operator one, and now the operator sees stale or wrong data, or worse, the tenant version gains a bug the operator version would have caught. Keeping both answers in one function means "who can see what" is a single decision with two outcomes, not two decisions that have to agree forever.
Separate everything that could leak. Share the one thing that has to stay consistent.
What this costs
Honesty about the trade: this is more code. Two login flows, two session mechanisms, two middleware chains, two sets of tests. A role column would have been a tenth of the work.
It also means operator accounts cannot self-serve. There is no password reset for HQLead, because there is no higher authority to authorise one — which is exactly why a second operator account exists, and why losing both means editing the database by hand. That's a real operational cost accepted on purpose.
And it adds a question to every new feature: which actor is this for? Most of the time the answer is "the tenant", but it has to be asked, and occasionally the answer is "both", which means two routes.
Why it was still right
The alternative isn't "less code" — it's "the same code with a more dangerous failure mode". In a role-column system, the distance between a tenant and the entire platform is one boolean. Here it's a different signing key, a different session store, a different table and a different middleware chain, and an attacker needs all four to line up.
For a system whose entire promise is that one landlord cannot see another's data, that's the correct place to spend the extra work.
See it running: mypropertyos.de — two landlord logins are published so you can test the isolation claim yourself. Credentials are on the PropertyOS project page.