feat(tenant): implement tenant context resolution and add interceptor

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-05 21:41:04 +08:00
parent 3ca8eed7cb
commit 2e117ecdd3
8 changed files with 153 additions and 46 deletions

View File

@@ -35,6 +35,8 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
globalPrefix: options.globalPrefix ?? '/api',
})
const container = app.getContainer()
app.useGlobalFilters(new AllExceptionsFilter())
app.useGlobalInterceptors(new LoggingInterceptor())
app.useGlobalInterceptors(new ResponseTransformInterceptor())
@@ -42,7 +44,6 @@ export async function createConfiguredApp(options: BootstrapOptions = {}): Promi
app.useGlobalPipes(new GlobalValidationPipe())
// Warm up DB connection during bootstrap
const container = app.getContainer()
const poolProvider = container.resolve(PgPoolProvider)
await poolProvider.warmup()

View File

@@ -5,6 +5,7 @@ import type { Session } from 'better-auth'
import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
import { getTenantContext } from 'core/modules/tenant/tenant.context'
import { TenantContextResolver } from 'core/modules/tenant/tenant-context-resolver.service'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@@ -30,13 +31,22 @@ export class AuthGuard implements CanActivate {
private readonly authProvider: AuthProvider,
private readonly tenantAuthProvider: TenantAuthProvider,
private readonly dbAccessor: DbAccessor,
private readonly tenantContextResolver: TenantContextResolver,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const store = context.getContext()
const { hono } = store
const tenantContext = getTenantContext()
let tenantContext = getTenantContext()
if (!tenantContext) {
const resolvedTenant = await this.tenantContextResolver.resolve(hono, {
setResponseHeaders: false,
})
HttpContext.setValue('tenant', tenantContext)
tenantContext = resolvedTenant ?? undefined
}
const { headers } = hono.req.raw

View File

@@ -0,0 +1,24 @@
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
export const TENANT_RESOLUTION_OPTIONS = Symbol('core:tenantResolutionOptions')
type DecoratorTarget = object | Function
function setMetadata(target: DecoratorTarget, options: TenantResolutionOptions): void {
Reflect.defineMetadata(TENANT_RESOLUTION_OPTIONS, options, target)
}
export function TenantResolution(options: TenantResolutionOptions): ClassDecorator & MethodDecorator {
return ((
target: DecoratorTarget,
_propertyKey?: string | symbol,
descriptor?: PropertyDescriptor,
): void | PropertyDescriptor => {
if (descriptor && descriptor.value) {
setMetadata(descriptor.value as DecoratorTarget, options)
return descriptor
}
setMetadata(target, options)
}) as unknown as ClassDecorator & MethodDecorator
}

View File

@@ -0,0 +1,54 @@
import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework'
import { injectable } from 'tsyringe'
import type { TenantResolutionOptions } from '../modules/tenant/tenant-context-resolver.service'
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
import { TENANT_RESOLUTION_OPTIONS } from './tenant-resolver.decorator'
const DEFAULT_OPTIONS: Required<TenantResolutionOptions> = {
throwOnMissing: true,
setResponseHeaders: true,
skipInitializationCheck: false,
}
function getResolutionOptions(target: object | Function | undefined): TenantResolutionOptions | undefined {
if (!target) {
return undefined
}
try {
return Reflect.getMetadata(TENANT_RESOLUTION_OPTIONS, target) as TenantResolutionOptions | undefined
} catch {
return undefined
}
}
function mergeOptions(
classOptions: TenantResolutionOptions | undefined,
handlerOptions: TenantResolutionOptions | undefined,
): TenantResolutionOptions {
return {
...DEFAULT_OPTIONS,
...classOptions,
...handlerOptions,
}
}
@injectable()
export class TenantResolverInterceptor implements Interceptor {
constructor(private readonly tenantContextResolver: TenantContextResolver) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<FrameworkResponse> {
const { hono } = context.getContext()
const handler = context.getHandler()
const clazz = context.getClass()
const classOptions = getResolutionOptions(clazz)
const handlerOptions = getResolutionOptions(handler)
const resolutionOptions = mergeOptions(classOptions, handlerOptions)
await this.tenantContextResolver.resolve(hono, resolutionOptions)
return await next.handle()
}
}

View File

@@ -8,7 +8,7 @@ import { injectable } from 'tsyringe'
import { logger } from '../helpers/logger.helper'
import { SettingService } from '../modules/setting/setting.service'
import { getTenantContext } from '../modules/tenant/tenant.context'
import { TenantService } from '../modules/tenant/tenant.service'
import { TenantContextResolver } from '../modules/tenant/tenant-context-resolver.service'
type AllowedOrigins = '*' | string[]
@@ -51,7 +51,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
constructor(
private readonly eventEmitter: EventEmitterService,
private readonly settingService: SettingService,
private readonly tenantService: TenantService,
private readonly tenantContextResolver: TenantContextResolver,
private readonly onboardingService: OnboardingService,
) {}
@@ -132,7 +132,11 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
return await next()
}
const tenantContext = getTenantContext()
const tenantContext = await this.tenantContextResolver.resolve(context, {
setResponseHeaders: false,
skipInitializationCheck: true,
})
const tenantId = tenantContext?.tenant.id
if (tenantId) {

View File

@@ -1,8 +1,8 @@
import { APP_GUARD, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
import { APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework'
import { AuthGuard } from 'core/guards/auth.guard'
import { TenantResolverInterceptor } from 'core/interceptors/tenant-resolver.interceptor'
import { CorsMiddleware } from 'core/middlewares/cors.middleware'
import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware'
import { TenantResolverMiddleware } from 'core/middlewares/tenant-resolver.middleware'
import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
@@ -52,10 +52,6 @@ function createEventModuleOptions(redis: RedisAccessor) {
provide: APP_MIDDLEWARE,
useClass: CorsMiddleware,
},
{
provide: APP_MIDDLEWARE,
useClass: TenantResolverMiddleware,
},
{
provide: APP_MIDDLEWARE,
useClass: DatabaseContextMiddleware,
@@ -65,6 +61,10 @@ function createEventModuleOptions(redis: RedisAccessor) {
provide: APP_GUARD,
useClass: AuthGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: TenantResolverInterceptor,
},
],
})
export class AppModules {}

View File

@@ -1,21 +1,26 @@
import type { HttpMiddleware } from '@afilmory/framework'
import { HttpContext, Middleware } from '@afilmory/framework'
import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import type { Context, Next } from 'hono'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { logger } from '../helpers/logger.helper'
import { OnboardingService } from '../modules/onboarding/onboarding.service'
import { SuperAdminSettingService } from '../modules/system-setting/super-admin-setting.service'
import { TenantService } from '../modules/tenant/tenant.service'
import { logger } from '../../helpers/logger.helper'
import { OnboardingService } from '../onboarding/onboarding.service'
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
import { TenantService } from './tenant.service'
import type { TenantContext } from './tenant.types'
const HEADER_TENANT_ID = 'x-tenant-id'
const HEADER_TENANT_SLUG = 'x-tenant-slug'
@Middleware()
export interface TenantResolutionOptions {
throwOnMissing?: boolean
setResponseHeaders?: boolean
skipInitializationCheck?: boolean
}
@injectable()
export class TenantResolverMiddleware implements HttpMiddleware {
export class TenantContextResolver {
private readonly log = logger.extend('TenantResolver')
constructor(
@@ -24,34 +29,23 @@ export class TenantResolverMiddleware implements HttpMiddleware {
private readonly superAdminSettingService: SuperAdminSettingService,
) {}
async use(context: Context, next: Next): Promise<Response | void> {
const { path } = context.req
// During onboarding (before any user/tenant exists), skip tenant resolution entirely
const initialized = await this.onboardingService.isInitialized()
if (!initialized) {
this.log.info(`Application not initialized yet, skip tenant resolution for ${path}`)
return await next()
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
const existing = this.getExistingContext()
if (existing) {
if (options.setResponseHeaders !== false) {
this.applyTenantHeaders(context, existing)
}
return existing
}
const tenantContext = await this.resolveTenantContext(context)
if (tenantContext) {
HttpContext.assign({ tenant: tenantContext })
if (!options.skipInitializationCheck) {
const initialized = await this.onboardingService.isInitialized()
if (!initialized) {
this.log.info(`Application not initialized yet, skip tenant resolution for ${context.req.path}`)
return null
}
}
const response = await next()
if (tenantContext) {
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
}
return response
}
private async resolveTenantContext(context: Context) {
const forwardedHost = context.req.header('x-forwarded-host')
const origin = context.req.header('origin')
const hostHeader = context.req.header('host')
@@ -86,15 +80,34 @@ export class TenantResolverMiddleware implements HttpMiddleware {
)
if (!tenantContext) {
if (tenantId || derivedSlug || host) {
if (options.throwOnMissing && (tenantId || derivedSlug)) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
return null
}
HttpContext.setValue('tenant', tenantContext)
if (options.setResponseHeaders !== false) {
this.applyTenantHeaders(context, tenantContext)
}
return tenantContext
}
private getExistingContext(): TenantContext | null {
try {
return (HttpContext.getValue('tenant') as TenantContext | undefined) ?? null
} catch {
return null
}
}
private applyTenantHeaders(context: Context, tenantContext: TenantContext): void {
context.header(HEADER_TENANT_ID, tenantContext.tenant.id)
context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug)
}
private async getBaseDomain(): Promise<string> {
if (process.env.NODE_ENV === 'development') {
return 'localhost'

View File

@@ -5,9 +5,10 @@ import { DatabaseModule } from 'core/database/database.module'
import { TenantRepository } from './tenant.repository'
import { TenantService } from './tenant.service'
import { TenantContextResolver } from './tenant-context-resolver.service'
@Module({
imports: [DatabaseModule],
providers: [TenantRepository, TenantService],
providers: [TenantRepository, TenantService, TenantContextResolver],
})
export class TenantModule {}