feat: per-tenant server access kill switch
Add Tenant.serverAccessBlocked, toggle API with appVersion bump and settings push, and expose stealth server_reachable in get_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -79,6 +79,8 @@ export class GetUserService {
|
||||
product: tenant.product,
|
||||
theme: themeMode,
|
||||
mode: themeMode,
|
||||
/** Stealth cold-boot gate — false looks like product server unreachable */
|
||||
server_reachable: !Boolean(tenant.serverAccessBlocked),
|
||||
/** Khanegi front-end family for consumers */
|
||||
ui_variant: isKhanegi ? uiVariant : undefined,
|
||||
app_ui_config:
|
||||
|
||||
@@ -78,4 +78,9 @@ export class RenewServerDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class SetServerAccessDto {
|
||||
@IsBoolean()
|
||||
blocked!: boolean;
|
||||
}
|
||||
|
||||
export { PLAN_MONTHS };
|
||||
|
||||
@@ -13,7 +13,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto';
|
||||
import { RenewServerDto, SetServerAccessDto, UpdateServerAccountDto } from './dto/server-account.dto';
|
||||
import { ServersService } from './servers.service';
|
||||
|
||||
@Controller('servers')
|
||||
@@ -45,6 +45,20 @@ export class ServersController {
|
||||
return this.serversService.getTenantServers(id);
|
||||
}
|
||||
|
||||
@Post('tenants/:id/server-access')
|
||||
@RequirePermissions('servers:write')
|
||||
setServerAccess(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: SetServerAccessDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.setServerAccessBlocked(
|
||||
id,
|
||||
dto.blocked,
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('tenants/:id/servers')
|
||||
@RequirePermissions('servers:write')
|
||||
createServer(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SettingsProxyModule } from '../settings-proxy/settings-proxy.module';
|
||||
import { ServersController } from './servers.controller';
|
||||
import { ServersService } from './servers.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
imports: [NotificationsModule, AuthModule, SettingsProxyModule],
|
||||
controllers: [ServersController],
|
||||
providers: [ServersService],
|
||||
exports: [ServersService],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Prisma, ServerAccount } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||
import { SettingsProxyService } from '../settings-proxy/settings-proxy.service';
|
||||
import {
|
||||
PLAN_MONTHS,
|
||||
RenewServerDto,
|
||||
@@ -45,6 +46,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notify: AdminNotifyService,
|
||||
private readonly settingsProxy: SettingsProxyService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -141,6 +143,8 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
nameEn: tenant.nameEn,
|
||||
domain: tenant.domain,
|
||||
avatarUrl: tenant.avatarUrl,
|
||||
serverAccessBlocked: Boolean(tenant.serverAccessBlocked),
|
||||
serverAccessBlockedAt: tenant.serverAccessBlockedAt,
|
||||
},
|
||||
servers: accounts.map((account) => ({
|
||||
account: this.publicAccount(account),
|
||||
@@ -669,4 +673,110 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill switch for this sold version's app+admin (physical server stays up).
|
||||
* Bumps appVersion and pushes flag to product /super/settings for immediate effect.
|
||||
*/
|
||||
async setServerAccessBlocked(
|
||||
tenantId: string,
|
||||
blocked: boolean,
|
||||
userId?: string,
|
||||
) {
|
||||
const before = await this.requireTenant(tenantId);
|
||||
if (Boolean(before.serverAccessBlocked) === blocked) {
|
||||
return {
|
||||
tenant: {
|
||||
id: before.id,
|
||||
serverAccessBlocked: Boolean(before.serverAccessBlocked),
|
||||
serverAccessBlockedAt: before.serverAccessBlockedAt,
|
||||
appVersion: before.appVersion,
|
||||
},
|
||||
push: { skipped: true, reason: 'unchanged' as const },
|
||||
};
|
||||
}
|
||||
|
||||
const nextVersion = this.bumpAppVersion(before.appVersion);
|
||||
const updated = await this.prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
serverAccessBlocked: blocked,
|
||||
serverAccessBlockedAt: blocked ? new Date() : null,
|
||||
appVersion: nextVersion,
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
action: blocked
|
||||
? 'tenant.server_access.block'
|
||||
: 'tenant.server_access.unblock',
|
||||
entityType: 'tenant',
|
||||
entityId: tenantId,
|
||||
tenantId,
|
||||
userId,
|
||||
before: {
|
||||
serverAccessBlocked: before.serverAccessBlocked,
|
||||
appVersion: before.appVersion,
|
||||
} as Prisma.InputJsonValue,
|
||||
after: {
|
||||
serverAccessBlocked: updated.serverAccessBlocked,
|
||||
appVersion: updated.appVersion,
|
||||
} as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
this.notify.notifyAdmins(
|
||||
blocked
|
||||
? `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» قطع شد (ظاهر قطعی سرور).`
|
||||
: `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» وصل شد.`,
|
||||
);
|
||||
|
||||
let push: {
|
||||
ok: boolean;
|
||||
stub?: boolean;
|
||||
message?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
try {
|
||||
const result = await this.settingsProxy.updateSettings(
|
||||
tenantId,
|
||||
{ server_access_blocked: blocked },
|
||||
userId,
|
||||
);
|
||||
push = {
|
||||
ok: true,
|
||||
stub: Boolean((result as { stub?: boolean })?.stub),
|
||||
message: (result as { message?: string })?.message,
|
||||
result,
|
||||
};
|
||||
} catch (error: any) {
|
||||
push = {
|
||||
ok: false,
|
||||
message:
|
||||
error?.message ||
|
||||
'قفل در رجیستری ثبت شد ولی push فوری به سرور نسخه ناموفق بود',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
id: updated.id,
|
||||
serverAccessBlocked: updated.serverAccessBlocked,
|
||||
serverAccessBlockedAt: updated.serverAccessBlockedAt,
|
||||
appVersion: updated.appVersion,
|
||||
},
|
||||
push,
|
||||
};
|
||||
}
|
||||
|
||||
private bumpAppVersion(current?: string | null): string {
|
||||
const v = String(current || '1').trim() || '1';
|
||||
if (/^\d+$/.test(v)) {
|
||||
return String(Number(v) + 1);
|
||||
}
|
||||
const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (semver) {
|
||||
return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`;
|
||||
}
|
||||
return `${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ import { SettingsProxyService } from './settings-proxy.service';
|
||||
imports: [AuthModule],
|
||||
controllers: [SettingsProxyController],
|
||||
providers: [SettingsProxyService],
|
||||
exports: [SettingsProxyService],
|
||||
})
|
||||
export class SettingsProxyModule {}
|
||||
|
||||
@@ -33,16 +33,13 @@ export class SettingsProxyService {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
this.joinUrl(tenant.nodeServer, '/super/settings'),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tenant.remoteApiToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
);
|
||||
timeout: 15000,
|
||||
});
|
||||
return { stub: false, settings: data };
|
||||
} catch (error) {
|
||||
throw new BadGatewayException(
|
||||
@@ -79,7 +76,7 @@ export class SettingsProxyService {
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
this.joinUrl(tenant.nodeServer, '/super/settings'),
|
||||
this.resolveSettingsUrl(tenant.nodeServer),
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
@@ -122,6 +119,18 @@ export class SettingsProxyService {
|
||||
return `${base.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* nodeServer may be `https://host` or `https://host/api`.
|
||||
* Laravel exposes both `/super/settings` (web) and `/api/super/settings`.
|
||||
*/
|
||||
private resolveSettingsUrl(nodeServer: string): string {
|
||||
const base = String(nodeServer || '').replace(/\/$/, '');
|
||||
if (/\/api$/i.test(base)) {
|
||||
return this.joinUrl(base, '/super/settings');
|
||||
}
|
||||
return this.joinUrl(base, '/api/super/settings');
|
||||
}
|
||||
|
||||
private axiosMessage(error: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
|
||||
Reference in New Issue
Block a user