While building authentication for the Villa Owner Trust System, I found that a Better Auth session did not contain enough information for the application to make business-level access decisions.
Better Auth could identify the signed-in user, but protected admin and owner requests also needed to know what that user represented inside the application:
Better Auth user and session
active application user
application role
management company
linked owner profiles
Those relationships belong to the application’s own PostgreSQL model rather than the Better Auth session.
I built a request-level SessionContext to connect those two layers.
For each relevant request, session.ts reads the Better Auth session, resolves the corresponding application access records through Drizzle, and returns one typed context object. Astro middleware stores that context in context.locals, making the same data available to pages, Actions, endpoints, and authorization code.
Better Auth identity and application access
Better Auth owns authentication:
user identity
login
logout
session
auth-owned tables
The Villa Owner Trust System owns its business access model:
application user
role
management company
owner profile links
property access
record visibility
proof visibility
statement access
export access
A valid Better Auth session therefore establishes identity, but it does not by itself grant access to the application’s business records.
The first application-level requirement is an active app_users record linked to the Better Auth user.
The relationship begins as:
Better Auth user + session
│
│ auth user ID
▼
app_users
│
├── role
├── management company
└── owner profile links
Keeping those models separate lets Better Auth remain responsible for authentication while the application owns the relationships and rules that determine business access.
SessionContext
The shared request context currently has this shape:
export type SessionContext = {
user: AuthSession["user"] | null;
session: AuthSession["session"] | null;
appUser: AppUserSessionContext | null;
role: AppUserRole | null;
managementCompanyId: string | null;
ownerProfileIds: string[];
};
Those values establish the base information needed by protected application code:
Is there a Better Auth session?
Is there an active application user?
What application role does the user have?
Which management company are they part of?
Which owner profiles are linked to them?
The context deliberately stops there.
It does not decide whether the user may access a particular property, expense, proof document, statement, or export. Those decisions belong to more specific authorization rules built on top of this request context.
Building the context in session.ts
session.ts connects the Better Auth session to the application’s Drizzle-backed access model.
The flow is:
read Better Auth session
↓
find active app_users record
↓
load role and management company
↓
load active owner profile links
↓
return SessionContext
The app_users lookup is an important boundary.
A user can have a valid Better Auth identity and session without having active access to the Villa Owner Trust System. Authentication therefore does not bypass the application’s own access records.
The Better Auth session type comes from the configured Better Auth instance:
type AuthSession = typeof auth.$Infer.Session;
AuthSession contains the inferred user and session types produced by the actual Better Auth configuration.
The application-user and role types come from the Drizzle schema:
export type AppUser = typeof appUsers.$inferSelect;
export type AppUserRole = AppUser["role"];
That keeps the TypeScript definitions tied to Better Auth and the Drizzle schema instead of redefining their shapes independently.
Login users and owner profiles remain separate
The Better Auth user is not the owner profile.
They represent different things:
Better Auth user
a login identity
Owner profile
a villa owner in the business model
The application connects them through its own access records:
Better Auth user
↓
app_users
↓
owner profile link
↓
owner profile
The session context currently exposes the linked owner profile IDs:
ownerProfileIds: string[];
It does not place owner information directly on the Better Auth user.
That separation allows the authentication model and ownership model to evolve independently. It also avoids assuming that business access will always remain a one-login-to-one-owner relationship.
For example, the model can support relationships such as:
one owner profile → multiple login users
one login user → multiple owner profiles
without changing Better Auth’s identity model.
Property access remains separate again. Being linked to an owner profile does not automatically grant access to every property.
Exposing SessionContext through Astro middleware
Astro middleware loads the session context for the relevant request surfaces and writes its values to context.locals.
The request path becomes:
request
↓
Astro middleware
↓
session.ts
↓
SessionContext
↓
context.locals
↓
Astro page / Action / endpoint / authorization code
The middleware assigns:
context.locals.user = sessionContext.user;
context.locals.session = sessionContext.session;
context.locals.appUser = sessionContext.appUser;
context.locals.role = sessionContext.role;
context.locals.managementCompanyId = sessionContext.managementCompanyId;
context.locals.ownerProfileIds = sessionContext.ownerProfileIds;
Astro pages can then read the same values through:
Astro.locals
API endpoints and Actions receive them through their request context.
This prevents each protected route or action from independently resolving the Better Auth session and application access records.
Route-aware context loading
The application does not resolve SessionContext for every request.
The middleware currently loads it for the route surfaces that need authentication or application access context:
/
/login
/admin/*
/owner/*
/api/files/*
/api/exports/*
/api/uploads/*
Better Auth’s own API routes are excluded:
/api/auth/*
Those routes are handled by the Better Auth handler itself and do not need the application’s session-context lookup wrapped around them.
The route matching helper is:
function isPathOrChild(pathname: string, basePath: string): boolean {
return pathname === basePath || pathname.startsWith(`${basePath}/`);
}
That keeps the route boundary explicit:
/admin → matches
/admin/properties → matches
/admin-tools → does not match
A request therefore receives application session context because it belongs to one of the intended route trees, not merely because its pathname begins with similar characters.
Typing Astro locals in env.d.ts
Astro’s App.Locals type is declared from the existing SessionContext:
type SessionContext = import("./lib/auth/session").SessionContext;
declare namespace App {
interface Locals extends SessionContext {}
}
env.d.ts does not load the session or run authentication code.
It gives TypeScript the shape of the values that middleware places in context.locals and that Astro exposes through Astro.locals.
Importing SessionContext also avoids maintaining a second locals definition.
The relationship remains:
session.ts
defines SessionContext
│
▼
env.d.ts
extends App.Locals
│
▼
Astro middleware, pages,
Actions, and endpoints
use the same types
Where authorization begins
After the middleware has populated Astro locals, protected application code starts with:
user
session
appUser
role
managementCompanyId
ownerProfileIds
Those values establish identity and application context.
More specific authorization can then answer questions such as:
Does this owner have access to this property?
Is this record owner-visible?
Can this owner view this proof document?
Can this user download this statement?
Does this admin belong to the record's management company?
The responsibilities therefore remain separate:
Better Auth
authenticates the user
SessionContext
resolves application access context
Authorization rules
decide access to a specific resource or operation
SessionContext is not the authorization system.
It is the request-level foundation that connects Better Auth to the Villa Owner Trust System’s Drizzle-backed access model and makes that context consistently available through Astro.