Добавьте бэкенд
Шаг 5 в разделе Ваш первый модуль показывает форму маршрута бэкенда — @ModuleCapability, @RequiresLicense — но не то, как его защитить. Эта страница — недостающая часть: минимальный бэкенд на NestJS, который проверяет токен вошедшего пользователя, принимает запросы из оболочки вашей песочницы и вызывает tv-api с этим же токеном. Бюджет: 20 минут.
SDK генерирует только фронтенд; у init-module сегодня нет опции для бэкенда. Каркаса, на котором это можно было бы проверить, не существует, поэтому каждый фрагмент ниже проверен напрямую: он проходит проверку типов под TypeScript 5.8 с NestJS 11.2.1 против опубликованного @tv/extension-sdk1.15.1, а guard — та же форма, что уже работает в продакшене у нескольких модулей (tv-module-service-desk, tv-module-checklists и другие) — скопирована и сокращена, а не написана по памяти. @tv/extension-sdk/nestjs не поставляет собственного помощника для проверки токена — вот что написать вместо него.
1. Установите зависимости
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
pnpm add -D typescript @types/express@tv/extension-sdk уже есть в зависимостях из Вашего первого модуля; здесь используются те же декораторы @ModuleCapability и @RequiresLicense.
Ловушка в tsconfig.json
@tv/extension-sdk/nestjs — это subpath-экспорт, и node-разрешение модулей в TypeScript не видит его без подсказки:
{
"compilerOptions": {
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@tv/extension-sdk/nestjs": ["node_modules/@tv/extension-sdk/dist/nestjs/index.d.ts"]
}
}
}Эта запись paths есть в каждом бэкенде модуля, который его импортирует. Без неё: Cannot find module '@tv/extension-sdk/nestjs'.
2. Проверьте токен вызывающего
Ваша песочница аутентифицируется через один общий realm Keycloak, sandboxes (см. Песочницы по запросу). Ваш бэкенд никогда не видит сессию оболочки — он видит только тот заголовок Authorization, который ему прислал фронтенд, — поэтому обязан проверить токен сам: подпись, алгоритм и издателя (issuer), по собственным ключам realm'а.
// src/jwt-auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { createPublicKey, createVerify, type KeyObject } from 'node:crypto';
/** Keycloak подписывает токены realm'а RS256. Всё остальное отклоняется сразу. */
const ALLOWED_ALG = 'RS256';
/** Минимальный интервал между перезапросами JWKS, чтобы неизвестный `kid` нельзя было использовать для нагрузки на realm. */
const REFETCH_COOLDOWN_MS = 10_000;
/** Допуск на рассинхронизацию часов между этим процессом и Keycloak. */
const CLOCK_SKEW_SEC = 30;
export interface VerifiedCaller {
sub?: string;
iss?: string;
exp?: number;
nbf?: number;
tenant_id?: string;
realm_access?: { roles?: string[] };
[claim: string]: unknown;
}
interface Jwk {
kid?: string;
kty?: string;
alg?: string;
use?: string;
}
function decodeSegment(segment: string): unknown {
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
}
export class TokenInvalidError extends Error {}
/** Забирает публичные ключи realm'а и проверяет по ним RS256-токены. */
export class JwksVerifier {
private keys = new Map<string, KeyObject>();
private lastFetch = 0;
private inFlight: Promise<void> | undefined;
constructor(
private readonly jwksUri: string,
private readonly issuer: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly now: () => number = Date.now,
) {}
async verify(token: string): Promise<VerifiedCaller> {
const parts = token.split('.');
if (parts.length !== 3) throw new TokenInvalidError('malformed token');
const [rawHeader, rawPayload, rawSignature] = parts;
let header: { alg?: string; kid?: string };
try {
header = decodeSegment(rawHeader) as { alg?: string; kid?: string };
} catch {
throw new TokenInvalidError('unreadable header');
}
// Проверяется до поиска ключа: это защита от alg-confusion, и
// `none` не должен дойти до шага проверки.
if (header.alg !== ALLOWED_ALG) {
throw new TokenInvalidError(`unsupported alg ${header.alg ?? '<none>'}`);
}
if (!header.kid) throw new TokenInvalidError('no kid');
const key = await this.keyFor(header.kid);
const ok = createVerify('RSA-SHA256')
.update(`${rawHeader}.${rawPayload}`)
.verify(key, Buffer.from(rawSignature, 'base64url'));
if (!ok) throw new TokenInvalidError('bad signature');
let payload: VerifiedCaller;
try {
payload = decodeSegment(rawPayload) as VerifiedCaller;
} catch {
throw new TokenInvalidError('unreadable payload');
}
// Имеет смысл только после того, как подпись сошлась.
if (payload.iss !== this.issuer) {
throw new TokenInvalidError('wrong issuer');
}
const nowSec = Math.floor(this.now() / 1000);
if (typeof payload.exp === 'number' && payload.exp + CLOCK_SKEW_SEC < nowSec) {
throw new TokenInvalidError('expired');
}
if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SEC > nowSec) {
throw new TokenInvalidError('not yet valid');
}
// Без проверки `aud`: Keycloak штампует его отдельно для каждого
// клиента, и жёсткая привязка к одному отклоняла бы легитимных
// вызывающих при добавлении в realm нового клиента. Issuer и подпись —
// вот что устанавливает «выпущено нашим realm'ом».
return payload;
}
private async keyFor(kid: string): Promise<KeyObject> {
const cached = this.keys.get(kid);
if (cached) return cached;
await this.refresh();
const key = this.keys.get(kid);
if (!key) throw new TokenInvalidError(`unknown kid ${kid}`);
return key;
}
/** Перезапрашивает набор ключей, объединяя параллельных вызывающих и ограничивая частоту. */
private async refresh(): Promise<void> {
if (this.inFlight) return this.inFlight;
if (this.now() - this.lastFetch < REFETCH_COOLDOWN_MS) return;
this.inFlight = (async () => {
try {
const response = await this.fetchImpl(this.jwksUri);
if (!response.ok) {
throw new TokenInvalidError(`JWKS fetch failed: ${response.status}`);
}
const body = (await response.json()) as { keys?: Jwk[] };
const next = new Map<string, KeyObject>();
for (const jwk of body.keys ?? []) {
if (!jwk.kid || jwk.kty !== 'RSA') continue;
if (jwk.use && jwk.use !== 'sig') continue;
if (jwk.alg && jwk.alg !== ALLOWED_ALG) continue;
try {
next.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' }));
} catch {
// Ключ, который не получилось импортировать, пропускается, а не
// валит весь набор целиком — realm может публиковать ключи и
// для алгоритмов, которые мы не принимаем.
}
}
this.keys = next;
this.lastFetch = this.now();
} finally {
this.inFlight = undefined;
}
})();
return this.inFlight;
}
}
let verifier: JwksVerifier | undefined | null;
/** Строится при первом обращении, чтобы отсутствующая переменная окружения проявлялась как 401, а не как падение при старте. */
function getVerifier(): JwksVerifier | null {
if (verifier !== undefined) return verifier;
const jwksUri = process.env.KEYCLOAK_JWKS_URI;
const issuer = process.env.KEYCLOAK_ISSUER;
verifier = jwksUri && issuer ? new JwksVerifier(jwksUri, issuer) : null;
return verifier;
}
/**
* Отказывает ЗАКРЫТО: если не заданы JWKS URI или issuer, либо realm
* недоступен, отклоняется каждый защищённый запрос. Бэкенд, неработающий во
* время простоя Keycloak, — правильный отказ здесь; альтернатива молча
* открывает ту самую дыру, для закрытия которой существует этот guard.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const jwks = getVerifier();
if (!jwks) {
this.logger.error(
'KEYCLOAK_JWKS_URI / KEYCLOAK_ISSUER are unset — rejecting every request.',
);
throw new UnauthorizedException('Backend is not configured for token verification');
}
const authHeader: string | undefined = request?.headers?.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Sign-in required');
}
try {
request.user = await jwks.verify(authHeader.slice(7));
return true;
} catch (error) {
this.logger.warn(
`Token rejected: ${error instanceof Error ? error.message : 'unknown error'}`,
);
throw new UnauthorizedException('Invalid token');
}
}
}Две переменные окружения, у обеих фиксированное значение для любой песочницы — они называют realm, а не ваш тенант:
| Переменная | Значение |
|---|---|
KEYCLOAK_ISSUER | https://auth.k8s.tangovision.dev/realms/sandboxes |
KEYCLOAK_JWKS_URI | https://auth.k8s.tangovision.dev/realms/sandboxes/protocol/openid-connect/certs |
Это не отделяет вашу песочницу от чужих
Realm sandboxes общий, и — по состоянию на 26.09.2026 — Keycloak пока не проверяет, что вход принадлежит tenant_id именно вашей песочницы. Проверенный токен доказывает, что он выпущен нашим realm'ом, а не то, что он принадлежит вашей организации. См. предупреждение в разделе Песочницы по запросу — это тот же самый пробел. Держите в песочнице только синтетические данные, как просит эта страница.
3. Подключите к маршруту
// src/hello.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { ModuleCapability, RequiresLicense } from '@tv/extension-sdk/nestjs';
import { JwtAuthGuard, VerifiedCaller } from './jwt-auth.guard';
interface AuthedRequest extends Request {
user?: VerifiedCaller;
}
@ModuleCapability({ id: 'hello.greeting', version: '1.0.0' })
@Controller('api/v1/buildings/:buildingId/hello')
@UseGuards(JwtAuthGuard)
export class HelloController {
// @RequiresLicense() — это только метаданные, см. предупреждение ниже.
// Он не заменяет @UseGuards(JwtAuthGuard) выше.
@RequiresLicense('@acme/module-hello')
@Get()
greet(@Req() req: AuthedRequest) {
return { message: `hello, ${req.user?.sub ?? 'stranger'}` };
}
}// src/health.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('health')
health(): { status: string; timestamp: string } {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}// src/app.module.ts
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HelloController } from './hello.controller';
@Module({ controllers: [HealthController, HelloController] })
export class AppModule {}@RequiresLicense() ничего не проверяет
То же предупреждение, что в разделе Ваш первый модуль: декоратор только записывает в метаданные, лицензия какого модуля нужна маршруту. При поступлении запроса это никто не проверяет — на вопрос «кто этот вызывающий» отвечает JwtAuthGuard выше; проверку лицензии всё равно пришлось бы писать отдельно.
4. Принимайте запросы из оболочки песочницы
Ваш локальный бэкенд и оболочка песочницы — разные origin'ы: оболочка на https://<slug>.sandbox.k8s.tangovision.dev, ваш бэкенд на http://localhost:<порт>, — а политика оболочки разрешает фронтенду обращаться к этому loopback-порту напрямую (см. Правила загрузки модуля с localhost, правило 9). Браузер отправляет ваш заголовок Authorization и при вызове с другого origin, поэтому CORS нужен с явным origin, а не с wildcard:
// src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.CORS_ORIGINS?.split(',') ?? [],
credentials: true,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();CORS_ORIGINS=https://<slug>.sandbox.k8s.tangovision.dev<slug> — это поддомен из apiUrl вашей песочницы, тот же, что в разделе Песочницы по запросу.
5. Вызывайте tv-api с токеном пользователя
У вашего бэкенда нет собственных прав на чтение данных тенанта. Пересылайте тот же заголовок Authorization, что прислал вызывающий, — проверенный на шаге 2, — а не тянитесь за administrator-токеном из sandbox connect: он для скриптов и заполнения данных, а не для ответа на запрос конкретного пользователя:
async function fetchSpaces(buildingId: string, authorization: string) {
const apiUrl = process.env.SANDBOX_API_URL; // https://<slug>.sandbox.k8s.tangovision.dev
const res = await fetch(
`${apiUrl}/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`,
{ headers: { authorization } },
);
if (!res.ok) throw new Error(`tv-api answered ${res.status} for spaces`);
return res.json();
}SANDBOX_API_URL — это тот же apiUrl вашей песочницы, тот же хост, что и в CORS_ORIGINS выше, без смены схемы. Это зеркалит то, что делает фронтенд вашего модуля через authorizationHeader() в разделе Вызов API из модуля; разница в том, что здесь заголовок читает ваш собственный код, а не SDK, потому что запрос NestJS — не тот fetch браузера, который оборачивает помощник SDK.
401 или 403 от tv-api на этом шаге означает, что что-то не так с вашим развёртыванием (рассинхронизация часов, неверный SANDBOX_API_URL), а не с конкретным вызывающим: ваш JwtAuthGuard только что принял этот же токен. Относитесь к этому как к сбою, а не как к «этому пользователю нельзя».
Что эта страница не покрывает
- MCP-эндпоинты (инструменты, которые вызывает Copilot) используют другую, обязательную схему — HMAC-подпись или realm-JWT, переписанный в контекст MCP, а не этот guard как есть. См. Аутентификация вашего MCP-эндпоинта.
- Ограничение данных одним тенантом, зданием или жителем сверх «токен валиден» — это забота вашего модуля уже после проверки вызывающего; эта страница останавливается на аутентификации.
- Ваш бэкенд не разворачивается никакой командой песочницы. Он работает на вашей машине и обращается к tv-api песочницы по сети, как любой другой клиент, — см. Чего ожидать, а чего нет.