Skip to content

Commit ee938d1

Browse files
committed
feat: implement root tenant login functionality
- Renamed core package to @afilmory/core for better namespace management. - Added AppInitializationModule and AppInitializationProvider to handle application startup and root tenant provisioning. - Introduced root tenant login page and updated routing to support root login. - Enhanced tenant context resolution to accommodate root tenant paths and added related utility functions. - Refactored existing services and modules to integrate root tenant logic and improve overall structure. Signed-off-by: Innei <tukon479@gmail.com>
1 parent d1b5cee commit ee938d1

File tree

19 files changed

+391
-55
lines changed

19 files changed

+391
-55
lines changed

be/apps/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "core",
2+
"name": "@afilmory/core",
33
"type": "module",
44
"version": "1.0.0",
55
"packageManager": "pnpm@10.18.0",

be/apps/core/src/middlewares/cors.middleware.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { HttpMiddleware, OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
22
import { EventEmitterService, Middleware } from '@afilmory/framework'
33
import { logger } from 'core/helpers/logger.helper'
4-
import { SettingService } from 'core/modules/configuration/setting/setting.service'
54
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
65
import { getTenantContext } from 'core/modules/platform/tenant/tenant.context'
76
import { TenantContextResolver } from 'core/modules/platform/tenant/tenant-context-resolver.service'
@@ -49,7 +48,7 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
4948
private readonly logger = logger.extend('CorsMiddleware')
5049
constructor(
5150
private readonly eventEmitter: EventEmitterService,
52-
private readonly settingService: SettingService,
51+
5352
private readonly tenantContextResolver: TenantContextResolver,
5453
private readonly appState: AppStateService,
5554
) {}
@@ -156,15 +155,15 @@ export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDes
156155
}
157156

158157
private async reloadAllowedOrigins(tenantId: string): Promise<void> {
159-
let raw: string | null = null
158+
// let raw: string | null = null
160159

161-
try {
162-
raw = await this.settingService.get('http.cors.allowedOrigins', { tenantId })
163-
} catch (error) {
164-
this.logger.warn('Failed to load CORS configuration from settings for tenant', tenantId, error)
165-
}
160+
// try {
161+
// raw = await this.settingService.get('http.cors.allowedOrigins', { tenantId })
162+
// } catch (error) {
163+
// this.logger.warn('Failed to load CORS configuration from settings for tenant', tenantId, error)
164+
// }
166165

167-
this.updateAllowedOrigins(tenantId, raw)
166+
this.updateAllowedOrigins(tenantId, null)
168167
}
169168

170169
private updateAllowedOrigins(tenantId: string, next: string | null): void {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@afilmory/framework'
2+
3+
import { AppStateModule } from '../infrastructure/app-state/app-state.module'
4+
import { AuthModule } from '../platform/auth/auth.module'
5+
import { RootAccountProvisioner } from '../platform/auth/root-account.service'
6+
import { TenantModule } from '../platform/tenant/tenant.module'
7+
import { AppInitializationProvider } from './app-initialization.provider'
8+
9+
@Module({
10+
imports: [AppStateModule, TenantModule, AuthModule],
11+
providers: [AppInitializationProvider, RootAccountProvisioner],
12+
})
13+
export class AppInitializationModule {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { OnModuleInit } from '@afilmory/framework'
2+
import { createLogger } from '@afilmory/framework'
3+
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
4+
import { TenantService } from 'core/modules/platform/tenant/tenant.service'
5+
import { injectable } from 'tsyringe'
6+
7+
import { RootAccountProvisioner } from '../platform/auth/root-account.service'
8+
9+
const log = createLogger('AppInitialization')
10+
11+
@injectable()
12+
export class AppInitializationProvider implements OnModuleInit {
13+
constructor(
14+
private readonly appState: AppStateService,
15+
private readonly tenantService: TenantService,
16+
private readonly rootAccountProvisioner: RootAccountProvisioner,
17+
) {}
18+
19+
async onModuleInit(): Promise<void> {
20+
const rootTenant = await this.tenantService.ensureRootTenant()
21+
const initialized = await this.appState.isInitialized()
22+
23+
if (!initialized) {
24+
log.info('Application not initialized. Provisioning root tenant and superadmin account...')
25+
await this.rootAccountProvisioner.ensureRootAccount(rootTenant.tenant.id)
26+
await this.appState.markInitialized()
27+
log.info('Application initialization completed.')
28+
return
29+
}
30+
31+
await this.rootAccountProvisioner.ensureRootAccount(rootTenant.tenant.id)
32+
}
33+
}

be/apps/core/src/modules/configuration/setting/setting.module.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,5 @@ import { SettingService } from './setting.service'
88
imports: [DatabaseModule],
99
providers: [SettingService],
1010
controllers: [SettingController],
11-
exports: [SettingService],
1211
})
1312
export class SettingModule {}

be/apps/core/src/modules/configuration/system-setting/system-setting.module.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,5 @@ import { SystemSettingStore } from './system-setting.store.service'
77
@Module({
88
imports: [DatabaseModule],
99
providers: [SystemSettingStore, SystemSettingService],
10-
exports: [SystemSettingStore, SystemSettingService],
1110
})
1211
export class SystemSettingModule {}

be/apps/core/src/modules/index.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
1010

1111
import { DatabaseModule } from '../database/database.module'
1212
import { RedisModule } from '../redis/redis.module'
13+
import { AppInitializationModule } from './app/app-initialization.module'
1314
import { BuilderSettingModule } from './configuration/builder-setting/builder-setting.module'
1415
import { SettingModule } from './configuration/setting/setting.module'
1516
import { SiteSettingModule } from './configuration/site-setting/site-setting.module'
@@ -58,6 +59,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
5859
DataSyncModule,
5960
FeedModule,
6061
OgModule,
62+
AppInitializationModule,
6163

6264
// This must be last
6365
StaticWebModule,

be/apps/core/src/modules/platform/auth/auth.module.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@ import { AuthConfig } from './auth.config'
99
import { AuthController } from './auth.controller'
1010
import { AuthProvider } from './auth.provider'
1111
import { AuthRegistrationService } from './auth-registration.service'
12-
import { RootAccountProvisioner } from './root-account.service'
1312

1413
@Module({
1514
imports: [DatabaseModule, SystemSettingModule, SettingModule, TenantModule, AppStateModule],
1615
controllers: [AuthController],
17-
providers: [AuthProvider, AuthConfig, AuthRegistrationService, RootAccountProvisioner],
16+
providers: [AuthProvider, AuthConfig, AuthRegistrationService],
1817
})
1918
export class AuthModule {}

be/apps/core/src/modules/platform/auth/root-account.service.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import { randomBytes } from 'node:crypto'
22

33
import { authUsers } from '@afilmory/db'
44
import { env } from '@afilmory/env'
5-
import type { OnModuleInit } from '@afilmory/framework'
65
import { createLogger } from '@afilmory/framework'
76
import { DbAccessor } from 'core/database/database.provider'
8-
import { AppStateService } from 'core/modules/infrastructure/app-state/app-state.service'
97
import { STATIC_DASHBOARD_BASENAME } from 'core/modules/infrastructure/static-web/static-dashboard.service'
108
import { eq } from 'drizzle-orm'
119
import { injectable } from 'tsyringe'
@@ -15,45 +13,43 @@ import { AuthProvider } from './auth.provider'
1513
const log = createLogger('RootAccount')
1614

1715
@injectable()
18-
export class RootAccountProvisioner implements OnModuleInit {
16+
export class RootAccountProvisioner {
1917
constructor(
2018
private readonly dbAccessor: DbAccessor,
2119
private readonly authProvider: AuthProvider,
22-
private readonly appState: AppStateService,
2320
) {}
2421

25-
async onModuleInit(): Promise<void> {
26-
const initialized = await this.appState.isInitialized()
27-
if (initialized) {
28-
return
29-
}
30-
await this.ensureRootAccount()
31-
}
32-
33-
private async ensureRootAccount(): Promise<void> {
22+
async ensureRootAccount(rootTenantId: string): Promise<void> {
3423
const db = this.dbAccessor.get()
3524
const email = env.DEFAULT_SUPERADMIN_EMAIL
3625
const username = env.DEFAULT_SUPERADMIN_USERNAME
3726

3827
const [existing] = await db
39-
.select({ id: authUsers.id, role: authUsers.role })
28+
.select({ id: authUsers.id, role: authUsers.role, tenantId: authUsers.tenantId })
4029
.from(authUsers)
4130
.where(eq(authUsers.email, email))
4231
.limit(1)
4332

4433
if (existing) {
45-
if (existing.role !== 'superadmin') {
34+
if (existing.role !== 'superadmin' || existing.tenantId !== rootTenantId) {
4635
await db
4736
.update(authUsers)
4837
.set({
4938
role: 'superadmin',
50-
tenantId: null,
39+
tenantId: rootTenantId,
5140
name: username,
5241
username,
5342
displayUsername: username,
5443
})
5544
.where(eq(authUsers.id, existing.id))
56-
log.info(`Existing account ${email} promoted to superadmin`)
45+
46+
const changeSummary =
47+
existing.role !== 'superadmin'
48+
? 'promoted to superadmin'
49+
: existing.tenantId !== rootTenantId
50+
? 'linked to root tenant'
51+
: 'updated'
52+
log.info(`Existing account ${email} ${changeSummary}`)
5753
} else {
5854
log.info('Root account already exists, skipping provisioning')
5955
}
@@ -78,7 +74,7 @@ export class RootAccountProvisioner implements OnModuleInit {
7874
.update(authUsers)
7975
.set({
8076
role: 'superadmin',
81-
tenantId: null,
77+
tenantId: rootTenantId,
8278
name: username,
8379
username,
8480
displayUsername: username,

be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { env } from '@afilmory/env'
12
import { HttpContext } from '@afilmory/framework'
23
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
34
import { BizException, ErrorCode } from 'core/errors'
@@ -7,12 +8,18 @@ import { AppStateService } from 'core/modules/infrastructure/app-state/app-state
78
import type { Context } from 'hono'
89
import { injectable } from 'tsyringe'
910

10-
import { PLACEHOLDER_TENANT_SLUG } from './tenant.constants'
11+
import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from './tenant.constants'
1112
import { TenantService } from './tenant.service'
1213
import type { TenantAggregate, TenantContext } from './tenant.types'
1314

1415
const HEADER_TENANT_ID = 'x-tenant-id'
1516
const HEADER_TENANT_SLUG = 'x-tenant-slug'
17+
const ROOT_TENANT_PATH_PREFIXES = [
18+
'/api/super-admin',
19+
'/api/settings',
20+
'/api/storage/settings',
21+
'/api/builder/settings',
22+
] as const
1623

1724
export interface TenantResolutionOptions {
1825
throwOnMissing?: boolean
@@ -52,12 +59,20 @@ export class TenantContextResolver {
5259
const hostHeader = context.req.header('host')
5360
const host = this.normalizeHost(forwardedHost ?? hostHeader ?? null, origin)
5461

62+
this.log.debug(`Forwarded host: ${forwardedHost}, Host header: ${hostHeader}, Origin: ${origin}, Host: ${host}`)
63+
5564
const tenantId = this.normalizeString(context.req.header(HEADER_TENANT_ID))
5665
const tenantSlugHeader = this.normalizeSlug(context.req.header(HEADER_TENANT_SLUG))
5766

5867
const baseDomain = await this.getBaseDomain()
5968

6069
let derivedSlug = host ? this.extractSlugFromHost(host, baseDomain) : undefined
70+
if (!derivedSlug && host && this.isBaseDomainHost(host, baseDomain)) {
71+
derivedSlug = ROOT_TENANT_SLUG
72+
}
73+
if (!derivedSlug && this.isRootTenantPath(context.req.path)) {
74+
derivedSlug = ROOT_TENANT_SLUG
75+
}
6176

6277
if (!derivedSlug) {
6378
derivedSlug = tenantSlugHeader
@@ -98,6 +113,64 @@ export class TenantContextResolver {
98113
return tenantContext
99114
}
100115

116+
private isBaseDomainHost(host: string, baseDomain: string): boolean {
117+
const parsed = this.parseHost(host)
118+
if (!parsed.hostname) {
119+
return false
120+
}
121+
122+
const normalizedHost = parsed.hostname.trim().toLowerCase()
123+
const normalizedBase = baseDomain.trim().toLowerCase()
124+
125+
if (normalizedBase === 'localhost') {
126+
return normalizedHost === 'localhost' && this.matchesServerPort(parsed.port)
127+
}
128+
129+
return normalizedHost === normalizedBase && this.matchesServerPort(parsed.port)
130+
}
131+
132+
private parseHost(host: string): { hostname: string | null; port: string | null } {
133+
if (!host) {
134+
return { hostname: null, port: null }
135+
}
136+
137+
if (host.startsWith('[')) {
138+
// IPv6 literal (e.g. [::1]:3000)
139+
const closingIndex = host.indexOf(']')
140+
if (closingIndex === -1) {
141+
return { hostname: host, port: null }
142+
}
143+
const hostname = host.slice(1, closingIndex)
144+
const portSegment = host.slice(closingIndex + 1)
145+
const port = portSegment.startsWith(':') ? portSegment.slice(1) : null
146+
return { hostname, port: port && port.length > 0 ? port : null }
147+
}
148+
149+
const [hostname, port] = host.split(':', 2)
150+
return { hostname: hostname ?? null, port: port ?? null }
151+
}
152+
153+
private matchesServerPort(port: string | null): boolean {
154+
if (!port) {
155+
return true
156+
}
157+
const parsed = Number.parseInt(port, 10)
158+
if (Number.isNaN(parsed)) {
159+
return false
160+
}
161+
return parsed === env.PORT
162+
}
163+
164+
private isRootTenantPath(path: string | undefined): boolean {
165+
if (!path) {
166+
return false
167+
}
168+
const normalizedPath = path.toLowerCase()
169+
return ROOT_TENANT_PATH_PREFIXES.some(
170+
(prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix.toLowerCase()}/`),
171+
)
172+
}
173+
101174
private getExistingContext(): TenantContext | null {
102175
try {
103176
return (HttpContext.getValue('tenant') as TenantContext | undefined) ?? null

0 commit comments

Comments
 (0)