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:
2026-08-11 16:05:04 +03:30
co-authored by Cursor
parent af1e217a68
commit 51e350405a
9 changed files with 164 additions and 12 deletions
@@ -0,0 +1,7 @@
-- Per-tenant app/admin access kill switch (shared physical servers stay up)
ALTER TABLE "tenants"
ADD COLUMN IF NOT EXISTS "server_access_blocked" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "tenants"
ADD COLUMN IF NOT EXISTS "server_access_blocked_at" TIMESTAMP(3);
+3
View File
@@ -76,6 +76,9 @@ model Tenant {
/// Client cache-bust / force-reload signal for this sold version (GET /get_version).
appVersion String @default("1") @map("app_version")
isActive Boolean @default(true) @map("is_active")
/// Kill switch: block this sold version's app+admin (server stays up). Not the same as ServerAccount billing.
serverAccessBlocked Boolean @default(false) @map("server_access_blocked")
serverAccessBlockedAt DateTime? @map("server_access_blocked_at")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+2
View File
@@ -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:
+5
View File
@@ -78,4 +78,9 @@ export class RenewServerDto {
note?: string;
}
export class SetServerAccessDto {
@IsBoolean()
blocked!: boolean;
}
export { PLAN_MONTHS };
+15 -1
View File
@@ -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(
+2 -1
View File
@@ -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],
+110
View File
@@ -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 {}
+19 -10
View File
@@ -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;