feat: implement multi-tenancy support in authentication module (#177)

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-29 13:57:18 +08:00
committed by GitHub
parent 843ff8130d
commit ae21438eb7
25 changed files with 4959 additions and 210 deletions

View File

@@ -6,7 +6,6 @@ import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.
import { BizException, ErrorCode } from 'core/errors'
import type { AuthSession } from 'core/modules/platform/auth/auth.provider'
import { getTenantContext, isPlaceholderTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
import type { TenantContext } from 'core/modules/platform/tenant/tenant.types'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@@ -18,10 +17,7 @@ import { logger } from '../helpers/logger.helper'
export class AuthGuard implements CanActivate {
private readonly log = logger.extend('AuthGuard')
constructor(
private readonly dbAccessor: DbAccessor,
private readonly tenantService: TenantService,
) {}
constructor(private readonly dbAccessor: DbAccessor) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const store = context.getContext()
@@ -51,11 +47,7 @@ export class AuthGuard implements CanActivate {
}
private async requireTenantContext(method: string, path: string): Promise<TenantContext> {
let tenantContext = getTenantContext()
if (!tenantContext && this.isPlaceholderAllowedPath(path)) {
tenantContext = (await this.createPlaceholderContext(method, path)) as TenantContext
}
const tenantContext = getTenantContext()
if (!tenantContext) {
this.log.warn(`Tenant context not resolved for ${method} ${path}`)
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
@@ -151,20 +143,4 @@ export class AuthGuard implements CanActivate {
return false
}
private async createPlaceholderContext(method: string, path: string): Promise<TenantContext | null> {
try {
const placeholder = await this.tenantService.ensurePlaceholderTenant()
const context: TenantContext = {
tenant: placeholder.tenant,
isPlaceholder: true,
}
HttpContext.setValue('tenant', context)
this.log.verbose(`Placeholder tenant context injected for ${method} ${path}`)
return context
} catch (error) {
this.log.error('Failed to inject placeholder tenant context', error)
return null
}
}
}

View File

@@ -6,6 +6,7 @@ import { BypassResponseTransform } from 'core/interceptors/response-transform.de
import { SettingKeys } from './setting.constant'
import type { GetSettingsBodyDto } from './setting.dto'
import { DeleteSettingDto, GetSettingDto, SetSettingDto } from './setting.dto'
import type { SettingEntryInput } from './setting.service'
import { SettingService } from './setting.service'
@Controller('settings')
@@ -48,7 +49,7 @@ export class SettingController {
@Post('/')
async set(@Body() { entries }: SetSettingDto) {
await this.settingService.setMany(entries)
await this.settingService.setMany(entries as SettingEntryInput[])
return { updated: entries }
}

View File

@@ -2,6 +2,7 @@ import { createZodDto } from '@afilmory/framework'
import { z } from 'zod'
import { SETTING_SCHEMAS, SettingKeys } from './setting.constant'
import type { SettingEntryInput } from './setting.service'
const keySchema = z.enum(SettingKeys)
@@ -17,7 +18,7 @@ const normalizeEntries = z
return entries.map((entry) => ({
key: entry.key,
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
}))
})) as SettingEntryInput[]
})
const keysInputSchema = z

View File

@@ -4,9 +4,10 @@ import { DbAccessor } from 'core/database/database.provider'
import { eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { SystemSettingKey as SystemSettingLiteralKey } from './system-setting.constants'
import type {
SystemSettingEntryInput,
SystemSettingKey,
SystemSettingKey as SystemSettingStoreKey,
SystemSettingRecord,
SystemSettingSetOptions,
} from './system-setting.store.types'
@@ -18,27 +19,29 @@ export class SystemSettingStore {
private readonly eventService: EventEmitterService,
) {}
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
async get(key: SystemSettingStoreKey): Promise<SystemSettingRecord['value']> {
const record = await this.find(key)
return record?.value ?? null
}
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
async getMany(
keys: readonly SystemSettingStoreKey[],
): Promise<Record<SystemSettingStoreKey, SystemSettingRecord['value']>> {
if (keys.length === 0) {
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
return {} as Record<SystemSettingStoreKey, SystemSettingRecord['value']>
}
const uniqueKeys = Array.from(new Set(keys))
const db = this.dbAccessor.get()
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
const map = new Map<SystemSettingStoreKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
return uniqueKeys.reduce(
(acc, key) => {
acc[key] = map.get(key)?.value ?? null
return acc
},
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
Object.create(null) as Record<SystemSettingStoreKey, SystemSettingRecord['value']>,
)
}
@@ -48,7 +51,7 @@ export class SystemSettingStore {
}
async set(
key: SystemSettingKey,
key: SystemSettingStoreKey,
value: SystemSettingRecord['value'],
options: SystemSettingSetOptions = {},
): Promise<void> {
@@ -78,7 +81,10 @@ export class SystemSettingStore {
},
})
this.eventService.emit('system.setting.updated', { key, value: String(value) })
this.eventService.emit('system.setting.updated', {
key: key as SystemSettingLiteralKey,
value: String(value),
})
}
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
@@ -87,12 +93,12 @@ export class SystemSettingStore {
}
}
async delete(key: SystemSettingKey): Promise<void> {
async delete(key: SystemSettingStoreKey): Promise<void> {
const db = this.dbAccessor.get()
await db.delete(systemSettings).where(eq(systemSettings.key, key))
}
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
private async find(key: SystemSettingStoreKey): Promise<SystemSettingRecord | null> {
return await this.findRaw(key)
}

View File

@@ -1,4 +1,4 @@
import { authUsers } from '@afilmory/db'
import { authUsers, tenants } from '@afilmory/db'
import { HttpContext } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors'
@@ -7,7 +7,7 @@ import type { SettingEntryInput } from 'core/modules/configuration/setting/setti
import { SettingService } from 'core/modules/configuration/setting/setting.service'
import type { SettingKeyType } from 'core/modules/configuration/setting/setting.type'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
@@ -65,7 +65,8 @@ export class AuthRegistrationService {
await this.systemSettings.ensureRegistrationAllowed()
const tenantContext = getTenantContext()
const effectiveTenantContext = isPlaceholderTenantContext(tenantContext) ? null : tenantContext
const isPendingTenant = tenantContext ? isPlaceholderTenantContext(tenantContext) : false
const effectiveTenantContext = isPendingTenant ? null : tenantContext
const account = input.account ? this.normalizeAccountInput(input.account) : null
const useSessionAccount = input.useSessionAccount ?? false
const sessionUser = this.getSessionUser()
@@ -74,6 +75,16 @@ export class AuthRegistrationService {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '请先登录后再创建工作区' })
}
if (isPendingTenant && tenantContext) {
return await this.finalizePendingTenant({
tenantContext,
tenantInput: input.tenant,
settings: input.settings,
sessionUser,
useSessionAccount,
})
}
if (effectiveTenantContext) {
if (useSessionAccount) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前租户上下文下不支持会话注册' })
@@ -195,18 +206,119 @@ export class AuthRegistrationService {
})
}
const schema = SETTING_SCHEMAS[key as SettingKeyType]
const typedKey = key as SettingKeyType
const schema = SETTING_SCHEMAS[typedKey]
const value = schema.parse(entry.value)
normalized.push({
key: key as SettingKeyType,
key: typedKey,
value,
})
} as SettingEntryInput)
}
return normalized
}
private async finalizePendingTenant(params: {
tenantContext: { tenant: TenantRecord; requestedSlug?: string | null }
tenantInput?: RegisterTenantInput['tenant']
settings?: RegisterTenantInput['settings']
sessionUser: AuthSession['user'] | null
useSessionAccount: boolean
}): Promise<RegisterTenantResult> {
const { tenantContext, tenantInput, settings, sessionUser, useSessionAccount } = params
if (!tenantInput) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' })
}
if (!useSessionAccount || !sessionUser) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '请通过已登录账号完成工作区初始化。',
})
}
const tenantName = tenantInput.name?.trim() ?? ''
if (!tenantName) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户名称不能为空' })
}
const currentSlug = tenantContext.tenant.slug?.toLowerCase() ?? ''
const requestedSlug =
tenantInput.slug?.trim().toLowerCase() ?? tenantContext.requestedSlug?.toLowerCase() ?? currentSlug
if (!requestedSlug || requestedSlug !== currentSlug) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '当前子域与请求的空间标识不匹配,无法完成注册。',
})
}
const sessionUserId = (sessionUser as { id?: string } | null)?.id
if (!sessionUserId) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED, { message: '当前登录状态无效,请重新登录。' })
}
const db = this.dbAccessor.get()
const [existingUser] = await db
.select({ tenantId: authUsers.tenantId })
.from(authUsers)
.where(eq(authUsers.id, sessionUserId))
.limit(1)
if (existingUser?.tenantId && existingUser.tenantId !== tenantContext.tenant.id) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '当前账号已属于其它工作区,无法重复注册。',
})
}
const now = new Date().toISOString()
const [updatedTenant] = await db
.update(tenants)
.set({
name: tenantName,
status: 'active',
updatedAt: now,
})
.where(and(eq(tenants.id, tenantContext.tenant.id), eq(tenants.status, 'pending')))
.returning()
if (!updatedTenant) {
throw new BizException(ErrorCode.COMMON_CONFLICT, {
message: '该空间已被其他用户绑定,请联系管理员。',
})
}
await db
.update(authUsers)
.set({
tenantId: updatedTenant.id,
role: 'admin',
name: sessionUser.name ?? sessionUser.email ?? 'Workspace Admin',
})
.where(eq(authUsers.id, sessionUserId))
const normalizedSettings = this.normalizeSettings(settings)
if (normalizedSettings.length > 0) {
await this.settingService.setMany(
normalizedSettings.map((entry) => ({
...entry,
options: {
tenantId: updatedTenant.id,
isSensitive: false,
},
})),
)
}
const response = new Response(JSON.stringify({ tenant: updatedTenant }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
return {
response,
tenant: updatedTenant,
accountId: sessionUserId,
success: true,
}
}
private async registerNewTenant(
account: RegisterTenantAccountInput | null,
tenantInput: RegisterTenantInput['tenant'],
@@ -347,8 +459,8 @@ export class AuthRegistrationService {
}
if (record.tenantId) {
const isPlaceholder = await this.tenantService.isPlaceholderTenantId(record.tenantId)
if (!isPlaceholder) {
const isPending = await this.tenantService.isPendingTenantId(record.tenantId)
if (!isPending) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '当前账号已属于其它工作区,无法重复注册。' })
}
}

View File

@@ -13,7 +13,6 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
import { eq } from 'drizzle-orm'
import type { Context } from 'hono'
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
import { getTenantContext, isPlaceholderTenantContext } from '../tenant/tenant.context'
import { TenantService } from '../tenant/tenant.service'
import type { TenantRecord } from '../tenant/tenant.types'
@@ -127,10 +126,10 @@ export class AuthController {
const { tenantId } = authContext.user as { tenantId?: string | null }
if (tenantId) {
try {
const aggregate = await this.tenantService.getById(tenantId)
const isPlaceholder = aggregate.tenant.slug === PLACEHOLDER_TENANT_SLUG
const aggregate = await this.tenantService.getById(tenantId, { allowPending: true })
const isPlaceholder = aggregate.tenant.status !== 'active'
const existingRequestedSlug = tenantContext?.requestedSlug ?? null
const derivedRequestedSlug = existingRequestedSlug ?? (isPlaceholder ? null : (aggregate.tenant.slug ?? null))
const derivedRequestedSlug = existingRequestedSlug ?? aggregate.tenant.slug ?? null
tenantContext = {
tenant: aggregate.tenant,
isPlaceholder,
@@ -308,12 +307,17 @@ export class AuthController {
const { headers } = context.req.raw
const tenantContext = getTenantContext()
// Only allow auto sign-up on real tenants (not placeholder)
// On placeholder tenant, users must explicitly register first
const isRealTenant = tenantContext && !isPlaceholderTenantContext(tenantContext)
const shouldAllowSignUp = body.requestSignUp ?? isRealTenant
const auth = await this.auth.getAuth()
const response = await auth.api.signInSocial({
body: {
...body,
provider,
requestSignUp: body.requestSignUp ?? Boolean(tenantContext),
requestSignUp: shouldAllowSignUp,
},
headers,
asResponse: true,

View File

@@ -7,7 +7,6 @@ import { createLogger, HttpContext } from '@afilmory/framework'
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
import { creem } from '@creem_io/better-auth'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError, createAuthMiddleware } from 'better-auth/api'
import { admin } from 'better-auth/plugins'
import { DrizzleProvider } from 'core/database/database.provider'
@@ -20,11 +19,11 @@ import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.s
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { PLACEHOLDER_TENANT_SLUG } from '../tenant/tenant.constants'
import { TenantService } from '../tenant/tenant.service'
import { extractTenantSlugFromHost } from '../tenant/tenant-host.utils'
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
import { AuthConfig } from './auth.config'
import { tenantAwareDrizzleAdapter } from './tenant-aware-adapter'
export type BetterAuthInstance = ReturnType<typeof betterAuth>
@@ -33,7 +32,6 @@ const logger = createLogger('Auth')
@injectable()
export class AuthProvider implements OnModuleInit {
private instances = new Map<string, Promise<BetterAuthInstance>>()
private placeholderTenantId: string | null = null
constructor(
private readonly config: AuthConfig,
@@ -83,16 +81,20 @@ export class AuthProvider implements OnModuleInit {
return sanitizedSlug ? `better-auth-${sanitizedSlug}` : 'better-auth'
}
private async resolveFallbackTenantId(): Promise<string | null> {
if (this.placeholderTenantId) {
return this.placeholderTenantId
private async resolveTenantIdOrProvision(tenantSlug: string | null): Promise<string | null> {
const tenantIdFromContext = this.resolveTenantIdFromContext()
if (tenantIdFromContext) {
return tenantIdFromContext
}
if (!tenantSlug) {
return null
}
try {
const placeholder = await this.tenantService.ensurePlaceholderTenant()
this.placeholderTenantId = placeholder.tenant.id
return this.placeholderTenantId
const aggregate = await this.tenantService.ensurePendingTenant(tenantSlug)
return aggregate.tenant.id
} catch (error) {
logger.error('Failed to ensure placeholder tenant', error)
logger.error(`Failed to provision tenant for slug=${tenantSlug}`, error)
return null
}
}
@@ -221,23 +223,42 @@ export class AuthProvider implements OnModuleInit {
)
const cookiePrefix = this.buildCookiePrefix(tenantSlug)
// Use tenant-aware adapter for multi-tenant user/account isolation
// This ensures that user lookups (by email) and account lookups (by provider)
// are scoped to the current tenant, allowing the same email/social account
// to exist as different users in different tenants
const ensureTenantId = async () => await this.resolveTenantIdOrProvision(tenantSlug)
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
subscription: creemSubscriptions,
database: tenantAwareDrizzleAdapter(
db,
{
provider: 'pg',
schema: {
user: authUsers,
session: authSessions,
account: authAccounts,
verification: authVerifications,
subscription: creemSubscriptions,
},
},
}),
ensureTenantId,
),
socialProviders: socialProviders as any,
emailAndPassword: { enabled: true },
trustedOrigins: await this.buildTrustedOrigins(),
session: {
freshAge: 0,
additionalFields: {
tenantId: { type: 'string', input: false },
},
},
account: {
additionalFields: {
tenantId: { type: 'string', input: false },
},
},
user: {
additionalFields: {
tenantId: { type: 'string', input: false },
@@ -249,27 +270,18 @@ export class AuthProvider implements OnModuleInit {
user: {
create: {
before: async (user) => {
const tenantId = this.resolveTenantIdFromContext()
if (tenantId) {
return {
data: {
...user,
tenantId,
role: user.role ?? 'guest',
},
}
}
const fallbackTenantId = await this.resolveFallbackTenantId()
if (!fallbackTenantId) {
return { data: user }
const tenantId = await ensureTenantId()
if (!tenantId) {
throw new APIError('BAD_REQUEST', {
message: 'Missing tenant context during account creation.',
})
}
return {
data: {
...user,
tenantId: fallbackTenantId,
role: user.role ?? 'guest',
tenantId,
role: user.role ?? 'user',
},
}
},
@@ -279,7 +291,7 @@ export class AuthProvider implements OnModuleInit {
create: {
before: async (session) => {
const tenantId = this.resolveTenantIdFromContext()
const fallbackTenantId = tenantId ?? session.tenantId ?? (await this.resolveFallbackTenantId())
const fallbackTenantId = tenantId ?? session.tenantId ?? (await ensureTenantId())
return {
data: {
...session,
@@ -293,7 +305,7 @@ export class AuthProvider implements OnModuleInit {
create: {
before: async (account) => {
const tenantId = this.resolveTenantIdFromContext()
const resolvedTenantId = tenantId ?? (await this.resolveFallbackTenantId())
const resolvedTenantId = tenantId ?? (await ensureTenantId())
if (!resolvedTenantId) {
return { data: account }
}
@@ -375,10 +387,7 @@ export class AuthProvider implements OnModuleInit {
const fallbackHost = options.baseDomain.trim().toLowerCase()
const requestedHost = (endpoint.host ?? fallbackHost).trim().toLowerCase()
const tenantSlugFromContext = this.resolveTenantSlugFromContext()
const tenantSlug =
tenantSlugFromContext && tenantSlugFromContext !== PLACEHOLDER_TENANT_SLUG
? tenantSlugFromContext
: (extractTenantSlugFromHost(requestedHost, options.baseDomain) ?? tenantSlugFromContext)
const tenantSlug = tenantSlugFromContext ?? extractTenantSlugFromHost(requestedHost, options.baseDomain)
const host = this.applyTenantSlugToHost(requestedHost || fallbackHost, fallbackHost, tenantSlug)
const protocol = this.determineProtocol(host, endpoint.protocol)

View File

@@ -0,0 +1,146 @@
import { HttpContext } from '@afilmory/framework'
import type { Adapter, Where } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { BizException, ErrorCode } from 'core/errors'
type DrizzleAdapterConfig = Parameters<typeof drizzleAdapter>[1]
type DrizzleDb = Parameters<typeof drizzleAdapter>[0]
type AdapterInstance = ReturnType<ReturnType<typeof drizzleAdapter>>
type FindOneParams = Parameters<Adapter['findOne']>[0]
type FindManyParams = Parameters<Adapter['findMany']>[0]
/**
* Creates a tenant-aware wrapper around the drizzle adapter.
*
* This wrapper intercepts findOne and findMany operations for 'user' and 'account' models
* and automatically injects tenantId filtering based on the current HTTP context.
*
* This enables true multi-tenancy where:
* - Same email can exist in different tenants as different users
* - Same social account (e.g., GitHub) can be linked to different users in different tenants
*/
export function tenantAwareDrizzleAdapter(
db: DrizzleDb,
config: DrizzleAdapterConfig,
getTenantId: () => string | null | Promise<string | null>,
): ReturnType<typeof drizzleAdapter> {
const baseAdapterFactory = drizzleAdapter(db, config)
return (options) => {
const baseAdapter = baseAdapterFactory(options)
const wrapFindOne = (originalFindOne: AdapterInstance['findOne']) => {
return async (params: FindOneParams) => {
const enhancedParams = await injectTenantFilterForFindOne(params, getTenantId)
return originalFindOne(enhancedParams)
}
}
const wrapFindMany = (originalFindMany: AdapterInstance['findMany']) => {
return async (params: FindManyParams) => {
const enhancedParams = await injectTenantFilterForFindMany(params, getTenantId)
return originalFindMany(enhancedParams)
}
}
return {
...baseAdapter,
findOne: wrapFindOne(baseAdapter.findOne),
findMany: wrapFindMany(baseAdapter.findMany),
} as AdapterInstance
}
}
/**
* Injects tenantId filter into the where clause for user and account models.
* Only applies to models that need tenant isolation.
*/
async function injectTenantFilterForFindOne(
params: FindOneParams,
getTenantId: () => string | null | Promise<string | null>,
): Promise<FindOneParams> {
const modelsRequiringTenantFilter = ['user', 'account', 'session']
if (!modelsRequiringTenantFilter.includes(params.model)) {
return params
}
const tenantId = await getTenantId()
if (!tenantId) {
// No tenant context - allow query to proceed without tenant filter
// This handles edge cases like initial setup or cross-tenant admin operations
throw new BizException(ErrorCode.TENANT_NOT_FOUND, {
message: 'Tenant Id is required',
})
}
const tenantFilter: Where = {
field: 'tenantId',
value: tenantId,
connector: 'AND',
}
const existingWhere = params.where ?? []
// Check if tenantId filter already exists
const hasTenantFilter = existingWhere.some((clause) => clause.field === 'tenantId')
if (hasTenantFilter) {
return params
}
return {
...params,
where: [...existingWhere, tenantFilter],
}
}
async function injectTenantFilterForFindMany(
params: FindManyParams,
getTenantId: () => string | null | Promise<string | null>,
): Promise<FindManyParams> {
const modelsRequiringTenantFilter = ['user', 'account', 'session']
if (!modelsRequiringTenantFilter.includes(params.model)) {
return params
}
const tenantId = await getTenantId()
if (!tenantId) {
return params
}
const tenantFilter: Where = {
field: 'tenantId',
value: tenantId,
connector: 'AND',
}
const existingWhere = params.where ?? []
const hasTenantFilter = existingWhere.some((clause) => clause.field === 'tenantId')
if (hasTenantFilter) {
return params
}
return {
...params,
where: [...existingWhere, tenantFilter],
}
}
/**
* Default tenant ID resolver that reads from HttpContext.
* Used when no custom resolver is provided.
*/
export function defaultTenantIdResolver(): string | null {
try {
const tenantContext = HttpContext.getValue('tenant') as { tenant?: { id?: string | null } } | undefined
return tenantContext?.tenant?.id ?? null
} catch {
return null
}
}

View File

@@ -18,7 +18,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
import { ROOT_TENANT_SLUG } from 'core/modules/platform/tenant/tenant.constants'
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
@@ -72,9 +72,9 @@ export class DataManagementService {
})
}
if (tenantSlug === ROOT_TENANT_SLUG || tenantSlug === PLACEHOLDER_TENANT_SLUG) {
if (tenantSlug === ROOT_TENANT_SLUG || tenant.tenant.status === 'pending') {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '系统租户无法通过此操作删除。',
message: '系统租户或未完成初始化的工作区无法通过此操作删除。',
})
}

View File

@@ -1,5 +1,5 @@
import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors'
import { logger } from 'core/helpers/logger.helper'
import { AppStateService } from 'core/modules/app/app-state/app-state.service'
@@ -7,7 +7,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from './tenant.constants'
import { ROOT_TENANT_SLUG } from './tenant.constants'
import { TenantService } from './tenant.service'
import type { TenantAggregate, TenantContext } from './tenant.types'
import { TenantDomainService } from './tenant-domain.service'
@@ -64,7 +64,7 @@ export class TenantContextResolver {
if (host) {
const domainMatch = await this.tenantDomainService.resolveTenantByDomain(host)
if (domainMatch) {
tenantContext = this.asTenantContext(domainMatch, false, domainMatch.tenant.slug)
tenantContext = this.asTenantContext(domainMatch, domainMatch.tenant.slug)
derivedSlug = domainMatch.tenant.slug
this.log.verbose(
`Resolved tenant by custom domain for request ${context.req.method} ${context.req.path} (host=${host})`,
@@ -72,6 +72,14 @@ export class TenantContextResolver {
}
}
if (!derivedSlug) {
// Allow resolving tenant from query param for OAuth callbacks (Gateway flow)
const querySlug = context.req.query('tenantSlug')
if (querySlug && context.req.path.startsWith('/api/auth/callback/')) {
derivedSlug = querySlug
}
}
if (!derivedSlug) {
derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined
}
@@ -89,22 +97,23 @@ export class TenantContextResolver {
{
slug: derivedSlug,
},
true,
{ noThrow: true, allowPending: true },
)
}
if (!tenantContext && this.shouldFallbackToPlaceholder(derivedSlug)) {
const placeholder = await this.tenantService.ensurePlaceholderTenant()
tenantContext = this.asTenantContext(placeholder, true, requestedSlug)
if (!tenantContext && this.shouldAutoProvisionTenant(derivedSlug, context.req.path)) {
const pendingSlug = derivedSlug as string
const pending = await this.tenantService.ensurePendingTenant(pendingSlug)
tenantContext = this.asTenantContext(pending, requestedSlug)
this.log.verbose(
`Applied placeholder tenant context for ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'})`,
`Provisioned pending tenant context for ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'})`,
)
} else if (tenantContext) {
tenantContext = this.asTenantContext(
tenantContext,
tenantContext.tenant.slug === PLACEHOLDER_TENANT_SLUG,
requestedSlug ?? tenantContext.tenant.slug ?? null,
)
tenantContext = {
tenant: tenantContext.tenant,
isPlaceholder: tenantContext.tenant.status !== 'active',
requestedSlug: requestedSlug ?? tenantContext.tenant.slug ?? null,
}
}
if (!tenantContext) {
@@ -175,18 +184,33 @@ export class TenantContextResolver {
}
}
private shouldFallbackToPlaceholder(slug?: string | null): boolean {
return !slug
private shouldAutoProvisionTenant(slug: string | null | undefined, path: string): boolean {
if (!slug || isTenantSlugReserved(slug)) {
return false
}
const normalizedPath = path?.trim() || ''
if (!normalizedPath) {
return false
}
if (normalizedPath === '/auth' || normalizedPath === '/auth/') {
return true
}
if (normalizedPath.startsWith('/auth/')) {
return true
}
if (normalizedPath === '/api/auth' || normalizedPath === '/api/auth/') {
return true
}
if (normalizedPath.startsWith('/api/auth/')) {
return true
}
return false
}
private asTenantContext(
source: TenantAggregate,
isPlaceholder: boolean,
requestedSlug: string | null,
): TenantContext {
private asTenantContext(source: TenantAggregate, requestedSlug: string | null): TenantContext {
return {
tenant: source.tenant,
isPlaceholder,
isPlaceholder: source.tenant.status !== 'active',
requestedSlug,
}
}

View File

@@ -1,5 +1,3 @@
export const PLACEHOLDER_TENANT_NAME = 'Pending Workspace'
export const PENDING_TENANT_DEFAULT_NAME = 'Pending Workspace'
export const ROOT_TENANT_NAME = 'System Control Room'
export const ROOT_TENANT_SLUG = 'root'
export { PLACEHOLDER_TENANT_SLUG } from '@afilmory/utils'

View File

@@ -1,7 +1,6 @@
import { HttpContext } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
import type { TenantContext } from './tenant.types'
export function getTenantContext<TRequired extends boolean = false>(options?: {
@@ -22,9 +21,8 @@ export function isPlaceholderTenantContext(context?: TenantContext | null): bool
if (!context) {
return false
}
if (context.isPlaceholder) {
return true
if (typeof context.isPlaceholder === 'boolean') {
return context.isPlaceholder
}
const slug = context.tenant.slug?.toLowerCase()
return slug === PLACEHOLDER_TENANT_SLUG
return context.tenant.status !== 'active'
}

View File

@@ -36,6 +36,7 @@ export class TenantRepository {
slug: string
planId?: BillingPlanId
storagePlanId?: string | null
status?: TenantAggregate['tenant']['status']
}): Promise<TenantAggregate> {
const db = this.dbAccessor.get()
const tenantId = generateId()
@@ -45,7 +46,7 @@ export class TenantRepository {
slug: payload.slug,
planId: payload.planId ?? 'free',
storagePlanId: payload.storagePlanId ?? null,
status: 'active',
status: payload.status ?? 'active',
}
await db.insert(tenants).values(tenantRecord)

View File

@@ -4,14 +4,9 @@ import { normalizeString } from 'core/helpers/normalize.helper'
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import { injectable } from 'tsyringe'
import {
PLACEHOLDER_TENANT_NAME,
PLACEHOLDER_TENANT_SLUG,
ROOT_TENANT_NAME,
ROOT_TENANT_SLUG,
} from './tenant.constants'
import { PENDING_TENANT_DEFAULT_NAME, ROOT_TENANT_NAME, ROOT_TENANT_SLUG } from './tenant.constants'
import { TenantRepository } from './tenant.repository'
import type { TenantAggregate, TenantContext, TenantResolutionInput } from './tenant.types'
import type { TenantAggregate, TenantContext, TenantRecord, TenantResolutionInput } from './tenant.types'
@injectable()
export class TenantService {
@@ -22,6 +17,7 @@ export class TenantService {
slug: string
planId?: BillingPlanId
storagePlanId?: string | null
status?: TenantRecord['status']
}): Promise<TenantAggregate> {
const normalizedSlug = this.normalizeSlug(payload.slug)
@@ -41,22 +37,6 @@ export class TenantService {
})
}
async ensurePlaceholderTenant(): Promise<TenantAggregate> {
const existing = await this.repository.findBySlug(PLACEHOLDER_TENANT_SLUG)
if (existing) {
return existing
}
return await this.repository.createTenant({
name: PLACEHOLDER_TENANT_NAME,
slug: PLACEHOLDER_TENANT_SLUG,
})
}
getPlaceholderTenantSlug(): string {
return PLACEHOLDER_TENANT_SLUG
}
async ensureRootTenant(): Promise<TenantAggregate> {
const existing = await this.repository.findBySlug(ROOT_TENANT_SLUG)
if (existing) {
@@ -69,17 +49,11 @@ export class TenantService {
})
}
async isPlaceholderTenantId(tenantId: string | null | undefined): Promise<boolean> {
if (!tenantId) {
return false
}
const placeholder = await this.ensurePlaceholderTenant()
return placeholder.tenant.id === tenantId
}
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
async resolve(input: TenantResolutionInput): Promise<TenantContext>
async resolve(input: TenantResolutionInput, noThrow = false): Promise<TenantContext | null> {
async resolve(
input: TenantResolutionInput,
options?: { noThrow?: boolean; allowPending?: boolean },
): Promise<TenantContext | null> {
const { noThrow = false, allowPending = false } = options ?? {}
const tenantId = normalizeString(input.tenantId)
const slug = this.normalizeSlug(input.slug)
@@ -107,23 +81,23 @@ export class TenantService {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
this.ensureTenantIsActive(aggregate.tenant)
this.ensureTenantIsActive(aggregate.tenant, { allowPending })
return {
tenant: aggregate.tenant,
}
}
async getById(id: string): Promise<TenantAggregate> {
async getById(id: string, options?: { allowPending?: boolean }): Promise<TenantAggregate> {
const aggregate = await this.repository.findById(id)
if (!aggregate) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
this.ensureTenantIsActive(aggregate.tenant)
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
return aggregate
}
async getBySlug(slug: string): Promise<TenantAggregate> {
async getBySlug(slug: string, options?: { allowPending?: boolean }): Promise<TenantAggregate> {
const normalized = this.normalizeSlug(slug)
if (!normalized) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
@@ -133,7 +107,7 @@ export class TenantService {
if (!aggregate) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
this.ensureTenantIsActive(aggregate.tenant)
this.ensureTenantIsActive(aggregate.tenant, { allowPending: options?.allowPending ?? false })
return aggregate
}
@@ -159,7 +133,8 @@ export class TenantService {
return existing === null
}
ensureTenantIsActive(tenant: TenantAggregate['tenant']): void {
ensureTenantIsActive(tenant: TenantAggregate['tenant'], options?: { allowPending?: boolean }): void {
const allowPending = options?.allowPending ?? false
if (tenant.banned) {
throw new BizException(ErrorCode.TENANT_BANNED)
}
@@ -168,11 +143,44 @@ export class TenantService {
throw new BizException(ErrorCode.TENANT_SUSPENDED)
}
if (tenant.status === 'pending' && allowPending) {
return
}
if (tenant.status !== 'active') {
throw new BizException(ErrorCode.TENANT_INACTIVE)
}
}
async ensurePendingTenant(slug: string): Promise<TenantAggregate> {
const normalized = this.normalizeSlug(slug)
if (!normalized) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND, { message: 'Tenant slug is required' })
}
const existing = await this.repository.findBySlug(normalized)
if (existing) {
return existing
}
return await this.createTenant({
name: PENDING_TENANT_DEFAULT_NAME,
slug: normalized,
status: 'pending',
})
}
async isPendingTenantId(tenantId: string | null | undefined): Promise<boolean> {
if (!tenantId) {
return false
}
const aggregate = await this.repository.findById(tenantId)
if (!aggregate) {
return false
}
return aggregate.tenant.status === 'pending'
}
private normalizeSlug(value?: string | null): string | null {
const normalized = normalizeString(value)
return normalized ? normalized.toLowerCase() : null

View File

@@ -17,6 +17,7 @@ const TENANT_MISSING_ERROR_CODES = new Set([AUTH_TENANT_NOT_FOUND_ERROR_CODE, TE
const {
LOGIN: DEFAULT_LOGIN_PATH,
ROOT_LOGIN: ROOT_LOGIN_PATH,
WELCOME: WELCOME_PATH,
TENANT_MISSING: TENANT_MISSING_PATH,
DEFAULT_AUTHENTICATED: DEFAULT_AUTHENTICATED_PATH,
SUPERADMIN_ROOT: SUPERADMIN_ROOT_PATH,
@@ -126,6 +127,9 @@ export function usePageRedirect() {
const isSuperAdmin = session?.user.role === 'superadmin'
const isOnSuperAdminPage = pathname.startsWith(SUPERADMIN_ROOT_PATH)
const isOnRootLoginPage = pathname === ROOT_LOGIN_PATH
const tenant = session?.tenant ?? null
const isTenantPending = Boolean(session && tenant?.isPlaceholder)
const isOnWelcomePage = pathname === WELCOME_PATH
if (session && isSuperAdmin) {
if (!isOnSuperAdminPage || pathname === DEFAULT_LOGIN_PATH || isOnRootLoginPage) {
@@ -134,6 +138,18 @@ export function usePageRedirect() {
return
}
if (session && isTenantPending) {
if (!isOnWelcomePage) {
navigate(WELCOME_PATH, { replace: true })
}
return
}
if (session && !isTenantPending && isOnWelcomePage) {
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
return
}
if (session && !isSuperAdmin && isOnSuperAdminPage) {
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
return
@@ -145,7 +161,8 @@ export function usePageRedirect() {
}
if (session && (pathname === DEFAULT_LOGIN_PATH || pathname === ROOT_LOGIN_PATH)) {
navigate(DEFAULT_AUTHENTICATED_PATH, { replace: true })
const destination = isTenantPending ? WELCOME_PATH : DEFAULT_AUTHENTICATED_PATH
navigate(destination, { replace: true })
}
}, [location, location.pathname, navigate, sessionQuery.data, sessionQuery.isError, sessionQuery.isPending])

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useSetAuthUser } from '~/atoms/auth'
import { ROUTE_PATHS } from '~/constants/routes'
import { AUTH_SESSION_QUERY_KEY, fetchSession } from '~/modules/auth/api/session'
import { buildRootTenantUrl, buildTenantUrl, getTenantSlugFromHost } from '~/modules/auth/utils/domain'
@@ -55,7 +56,12 @@ export function useLogin() {
const { tenant } = session
const isSuperAdmin = session.user.role === 'superadmin'
if (tenant && !tenant.isPlaceholder && tenant.slug) {
if (tenant?.isPlaceholder) {
navigate(ROUTE_PATHS.WELCOME, { replace: true })
return
}
if (tenant && tenant.slug) {
const currentSlug = getTenantSlugFromHost(window.location.hostname)
if (!isSuperAdmin && tenant.slug !== currentSlug) {
try {

View File

@@ -0,0 +1,11 @@
ALTER TABLE "auth_user" DROP CONSTRAINT "auth_user_email_unique";--> statement-breakpoint
ALTER TABLE "photo_asset" ALTER COLUMN "manifest_version" SET DEFAULT 'v10';--> statement-breakpoint
ALTER TABLE "auth_account" ADD COLUMN "tenant_id" text;--> statement-breakpoint
ALTER TABLE "auth_account" ADD CONSTRAINT "auth_account_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_auth_account_user" ON "auth_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_auth_account_tenant" ON "auth_account" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "idx_auth_account_provider" ON "auth_account" USING btree ("provider_id","account_id");--> statement-breakpoint
CREATE INDEX "idx_auth_user_email" ON "auth_user" USING btree ("email");--> statement-breakpoint
CREATE INDEX "idx_auth_user_tenant" ON "auth_user" USING btree ("tenant_id");--> statement-breakpoint
ALTER TABLE "auth_account" ADD CONSTRAINT "uq_auth_account_tenant_provider" UNIQUE("tenant_id","provider_id","account_id");--> statement-breakpoint
ALTER TABLE "auth_user" ADD CONSTRAINT "uq_auth_user_tenant_email" UNIQUE("tenant_id","email");

View File

@@ -0,0 +1,7 @@
UPDATE "auth_account"
SET "tenant_id" = "auth_user"."tenant_id"
FROM "auth_user"
WHERE "auth_account"."user_id" = "auth_user"."id"
AND "auth_account"."tenant_id" IS NULL
AND "auth_user"."tenant_id" IS NOT NULL;--> statement-breakpoint

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."tenant_status" ADD VALUE 'pending' BEFORE 'active';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,27 @@
"when": 1764154685207,
"tag": "0010_wise_doorman",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1764226846630,
"tag": "0011_omniscient_wraith",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1764300000000,
"tag": "0012_populate_auth_account_tenant_id",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1764394870840,
"tag": "0013_blushing_crystal",
"breakpoints": true
}
]
}

View File

@@ -29,7 +29,7 @@ const snowflakeId = createSnowflakeId('id').primaryKey()
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'superadmin'])
export const tenantStatusEnum = pgEnum('tenant_status', ['active', 'inactive', 'suspended'])
export const tenantStatusEnum = pgEnum('tenant_status', ['pending', 'active', 'inactive', 'suspended'])
export const tenantDomainStatusEnum = pgEnum('tenant_domain_status', ['pending', 'verified', 'disabled'])
export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict'])
export const commentStatusEnum = pgEnum('comment_status', ['pending', 'approved', 'rejected', 'hidden'])
@@ -107,24 +107,34 @@ export const tenantDomains = pgTable(
)
// Custom users table (Better Auth: user)
export const authUsers = pgTable('auth_user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
creemCustomerId: text('creem_customer_id'),
role: userRoleEnum('role').notNull().default('user'),
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
username: text('username'),
displayUsername: text('display_username'),
banned: boolean('banned').default(false).notNull(),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
})
// Note: Multi-tenant design - same email can exist in different tenants
export const authUsers = pgTable(
'auth_user',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
creemCustomerId: text('creem_customer_id'),
role: userRoleEnum('role').notNull().default('user'),
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
twoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
username: text('username'),
displayUsername: text('display_username'),
banned: boolean('banned').default(false).notNull(),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires_at', { mode: 'string' }),
},
(t) => [
// Multi-tenant: same email can exist in different tenants
unique('uq_auth_user_tenant_email').on(t.tenantId, t.email),
index('idx_auth_user_email').on(t.email),
index('idx_auth_user_tenant').on(t.tenantId),
],
)
// Custom sessions table (Better Auth: session)
export const authSessions = pgTable('auth_session', {
@@ -142,23 +152,35 @@ export const authSessions = pgTable('auth_session', {
})
// Custom accounts table (Better Auth: account)
export const authAccounts = pgTable('auth_account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => authUsers.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
// Note: Multi-tenant design - same social account can exist in different tenants
export const authAccounts = pgTable(
'auth_account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => authUsers.id, { onDelete: 'cascade' }),
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { mode: 'string' }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { mode: 'string' }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
},
(t) => [
// Multi-tenant: same social account can exist in different tenants
unique('uq_auth_account_tenant_provider').on(t.tenantId, t.providerId, t.accountId),
index('idx_auth_account_user').on(t.userId),
index('idx_auth_account_tenant').on(t.tenantId),
index('idx_auth_account_provider').on(t.providerId, t.accountId),
],
)
export const authVerifications = pgTable('auth_verification', {
id: text('id').primaryKey(),

View File

@@ -9,8 +9,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
- Calls `AuthProvider.getAuth()` so downstream handlers reuse the tenant-aware Better Auth instance.
2. `TenantContextResolver` inspects `x-forwarded-host`, `origin`, and `host` headers.
- Extracts a slug via `tenant-host.utils.ts`.
- Loads the tenant aggregate; if none exists, falls back to the placeholder tenant.
- Always stores the original `requestedSlug` (even when placeholder) so downstream services know which workspace was requested.
- Loads the tenant aggregate; when a subdomain hits `/auth` or `/api/auth` for the first time, it auto-provisions a real tenant record with `status = "pending"` so auth flows have a fully-qualified tenant id.
- Always stores the original `requestedSlug` so downstream services know which workspace was requested.
## Auth Provider
@@ -48,7 +48,7 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
}
```
- When the resolver falls back to the placeholder tenant, `tenant.slug` still holds the requested subdomain, and `isPlaceholder` is `true`.
- When a tenant is still provisioning (`status = "pending"`), `tenant.slug` still holds the requested subdomain, `isPlaceholder` is `true`, and the dashboard stays on the onboarding surface.
- Consumers simply check `tenant.isPlaceholder` to know whether they are in onboarding.
## Dashboard Behavior
@@ -68,8 +68,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
3. User clicks “Sign in with GitHub” → `/auth/social` uses `requestedSlug` and redirects via the OAuth gateway.
4. Gateway forwards the callback to `https://slug.example.com/api/auth/callback/github`.
5. Resolver again sets `requestedSlug = "slug"`; Better Auth instance cache hits, so `state` matches.
6. `/auth/session` returns `{ tenant: { slug: "slug", isPlaceholder: true } }` → dashboard stays on welcome, no cross-subdomain jump.
7. Once the tenant is provisioned, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
6. `/auth/session` returns `{ tenant: { slug: "slug", isPlaceholder: true } }` while the workspace is pending → dashboard stays on welcome, no cross-subdomain jump.
7. Once the onboarding API marks the tenant `active`, future sessions have `isPlaceholder: false`, and `usePageRedirect` ensures we land on the actual workspace subdomain.
## Key Guarantees

View File

@@ -52,5 +52,3 @@ export function isTenantSlugReserved(slug: string): boolean {
}
export const DEFAULT_BASE_DOMAIN = 'afilmory.art'
export const PLACEHOLDER_TENANT_SLUG = 'holding'