Journey LOS
Config-first, multi-tenant Loan Origination System for UK building societies and specialist lenders — a from-scratch RBAC engine (65-table schema, three-layer nav → section → field permissions) built so a new lender is onboarded by data entry, not a code deployment.
Loan origination platforms I've worked on professionally tend to bake one lender's org chart into the product — role names like "underwriter" or "senior underwriter" hardcoded, along with which fields each role can see. That works until the next lender has a different structure: a small building society might have a flat 12-person team; a larger one has managers, regional directors, and a formal approval hierarchy. Shipping code to support every new lender's org chart doesn't scale.
Journey LOS is a personal exploration of the alternative: what if the entire permission model — roles, workflow stages, which fields are visible or editable at each stage — was configuration a lender's admin enters, not code a development team ships?
UK building societies and specialist lenders — the institution is the tenant. Within one case, internal staff (advisors, underwriters, processors, compliance), broker firms, solicitors, valuers, and the customer themselves all work the same case through a role-appropriate view, enforced by permissions rather than separate apps.
Solo engineering team, alongside a full-time product role at TCS. This is the same domain I work in professionally — UK mortgage and loan origination — built here as a from-scratch personal exploration of how far a fully configurable permission model can go before it needs custom code per lender.
Journey LOS is a hub-and-spoke multi-tenant platform: one hub, multiple lender institutions, with every party on a case (internal staff, brokers, solicitors, valuers, customers) accessing the same case data through a role-appropriate view rather than separate applications.
Auth was rebuilt from scratch, on purpose. It originally used Clerk. I pulled it out — a third-party service sitting on the critical auth path, a JWT claim payload too thin for rich RBAC data, per-MAU pricing at scale, and SSO integration being harder with Clerk as an intermediary. In its place: PBKDF2-SHA256 password hashing (100,000 iterations, 16-byte salt, pure WebCrypto, no npm dependency) and hand-rolled HS256 JWTs — the same "no external auth library" instinct as InventPro, but carrying a much richer payload here. Every token encodes the caller's institution, role, role level, permission list, and visibility scope directly, so authorization needs zero database lookups per request.
Permission is layered three ways. The same case view renders differently per role and per workflow stage, checked at three levels: can you reach this page at all (nav), can you see this section and is it editable (section, per workflow stage), can you see this specific field and edit it (field, down to a per-field sensitivity tier). See the diagram below.
Config-first RBAC, not fixed roles. role_definitions stores each institution's org chart as data — role label, level, approval limit, visibility scope — checked against a ~60-entry permission_catalog. Permission checks support wildcard matching (entity.* grants entity.case.create, entity.case.edit, etc.) via a single hasPermission() function, wired into every protected route through a requirePermission() Hono middleware — see the code snippet.
A genuinely large domain model. 65 tables across 38 migrations: cases carry an array of case types so a Porting + Further Advance can share one case, one ESIS, and one customer set; facilities model Part A/Part B loans independently; collateral has its own lifecycle (proposed → owned → pledged → charged → released) and can be flagged, not blocked, when re-pledged across active cases; customers carry versioned financial profiles with a configurable staleness window that gates workflow progression.
Honest state. This is proof-of-concept stage. The RBAC middleware and schema described above are real and wired into all 28 API route files — that part works. Some of the design document's more ambitious enforcement patterns, like query-level Chinese-wall separation between broker-sourced and internally-sourced cases, are specified in the role schema (case_source_filter) but not yet enforced in every case query. I'm building this in the open about what's actually implemented versus designed.
// Checks that the user holds at least one of the required permissions.
// Supports wildcard suffix matching: 'entity.*' grants 'entity.case.create', etc.
export function hasPermission(userPermissions: string[], required: string): boolean {
return userPermissions.some(p => {
if (p === '*') return true;
if (p === required) return true;
if (p.endsWith('.*')) {
const prefix = p.slice(0, -2);
return required === prefix || required.startsWith(prefix + '.');
}
return false;
});
}
export const requirePermission = (permission: string) =>
createMiddleware<{ Bindings: Env }>(async (c, next) => {
const user = c.get('user');
if (!hasPermission(user.permissions, permission)) {
return c.json({ success: false, error: 'Forbidden: insufficient permissions' }, 403);
}
await next();
});
Proof-of-concept stage — not deployed anywhere live, and not trying to look otherwise. What's real: a 65-table schema across 38 migrations, a three-layer permission model wired into all 28 API route files, and a working decision to rip out a third-party auth provider mid-build once it stopped fitting the RBAC model, rather than bend the model to fit the provider.
The open question this was built to answer — whether a fully config-driven permission model can absorb a new lender's org chart without a code change — is answered for the parts that are wired up. The parts still marked as designed-but-not-enforced (like the Chinese-wall query filtering) are the honest boundary of what's actually done versus what's specified.