Add a backend
Your first module step 5 shows the shape of a backend route — @ModuleCapability, @RequiresLicense — but not how to secure it. This page is the rest of it: a minimal NestJS backend that verifies the signed-in user's token, accepts calls from your sandbox shell, and calls tv-api with that same token. Budget: 20 minutes.
The SDK scaffolds a frontend only; init-module has no backend option today. There is no scaffold to check this against, so instead every snippet below is checked directly: it type-checks under TypeScript 5.8 with NestJS 11.2.1 against the published @tv/extension-sdk 1.15.1, and the guard is the same shape already running in production for several modules (tv-module-service-desk, tv-module-checklists and others) — copied and trimmed, not written from memory. @tv/extension-sdk/nestjs ships no token-verification helper of its own; this is what to write instead.
1. Install
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
pnpm add -D typescript @types/express@tv/extension-sdk is already a dependency from Your first module; this reuses its @ModuleCapability and @RequiresLicense decorators.
A tsconfig.json gotcha
@tv/extension-sdk/nestjs is a subpath export, and TypeScript's node module resolution can't see it without help:
{
"compilerOptions": {
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@tv/extension-sdk/nestjs": ["node_modules/@tv/extension-sdk/dist/nestjs/index.d.ts"]
}
}
}Every module backend that imports it carries this same paths entry. Without it: Cannot find module '@tv/extension-sdk/nestjs'.
2. Verify the caller's token
Your sandbox authenticates through one shared Keycloak realm, sandboxes (see On-demand sandboxes). Your backend never sees the shell's session — it only sees whatever Authorization header the frontend sends it — so it must verify that token itself: signature, algorithm and issuer, against the realm's own keys.
// src/jwt-auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { createPublicKey, createVerify, type KeyObject } from 'node:crypto';
/** Keycloak signs realm tokens RS256. Anything else is refused outright. */
const ALLOWED_ALG = 'RS256';
/** Floor between JWKS refetches, so an unknown `kid` can't be used to hammer the realm. */
const REFETCH_COOLDOWN_MS = 10_000;
/** Tolerance for clock drift between this process and 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 {}
/** Fetches the realm's public keys and verifies RS256 JWTs against them. */
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');
}
// Checked before any key lookup: this is the alg-confusion guard, and
// `none` must never reach the verify step.
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');
}
// Only meaningful after the signature checks out.
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');
}
// No `aud` check: Keycloak stamps it per client, so pinning one would
// reject legitimate callers the next time a client is added to the
// realm. Issuer + signature is what establishes "minted by our 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;
}
/** Refetches the key set, coalescing concurrent callers and rate-limiting. */
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 {
// A key we cannot import is skipped rather than failing the
// whole set — the realm may publish keys for algorithms we
// don't accept.
}
}
this.keys = next;
this.lastFetch = this.now();
} finally {
this.inFlight = undefined;
}
})();
return this.inFlight;
}
}
let verifier: JwksVerifier | undefined | null;
/** Built on first use so a missing env var surfaces as 401, not a boot crash. */
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;
}
/**
* Fails CLOSED: if the JWKS URI or issuer is unset, or the realm is
* unreachable, every guarded request is rejected. A backend that is
* unusable during a Keycloak outage is the correct failure here; the
* alternative silently reopens the hole this guard exists to close.
*/
@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');
}
}
}Two env vars, both fixed values for every sandbox — they name the realm, not your tenant:
| Variable | Value |
|---|---|
KEYCLOAK_ISSUER | https://auth.k8s.tangovision.dev/realms/sandboxes |
KEYCLOAK_JWKS_URI | https://auth.k8s.tangovision.dev/realms/sandboxes/protocol/openid-connect/certs |
This does not separate your sandbox from others
The sandboxes realm is shared, and — as of 2026-09-26 — Keycloak does not yet enforce that a login belongs to your sandbox's own tenant_id. A verified token proves it was minted by our realm, not that it belongs to your organisation. See the warning in On-demand sandboxes — this is the same gap. Keep synthetic data only, as that page asks.
3. Wire it into a route
// 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() is metadata only — see the warning below. It does
// not stand in for @UseGuards(JwtAuthGuard) above.
@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() does not enforce anything
Same warning as Your first module: it only records, as metadata, which module's licence a route needs. Nothing checks it when a request arrives — JwtAuthGuard above is what answers "who is this caller"; a licence check is a separate thing you would still have to write.
4. Accept calls from your sandbox shell
Your local backend and the sandbox shell are different origins — the shell is https://<slug>.sandbox.k8s.tangovision.dev, your backend is http://localhost:<port> — and the shell's policy lets your frontend call that loopback port directly (see The rules for loading a module from localhost, rule 9). The browser sends your Authorization header on that cross-origin call, so CORS needs an explicit origin, not a 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> is the subdomain of your sandbox's apiUrl — the same one from On-demand sandboxes.
5. Call tv-api with the user's token
Your backend holds no credential of its own for reading tenant data. Forward the same Authorization header the caller sent you — verified in step 2 — rather than reaching for sandbox connect's administrator token, which is for scripts and seeding, never for answering a specific user's request:
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 is your sandbox's apiUrl again — the same host as CORS_ORIGINS above, without the scheme change. This mirrors what your module's frontend does with authorizationHeader() in Calling the API from a module; the difference is that here your own code reads the header instead of the SDK reading it for you, because a NestJS request is not the browser fetch the SDK helper wraps.
A 401 or 403 from tv-api at this point means something is wrong with your deployment (clock skew, the wrong SANDBOX_API_URL), not with this specific caller: your JwtAuthGuard accepted the same token moments ago. Treat it as a fault, not as "this user is unauthorized."
What this does not cover
- MCP endpoints (tools Copilot calls) use a different, mandatory scheme — an HMAC signature or a realm JWT rewritten into the MCP context, never this guard as-is. See Authenticating your MCP endpoint.
- Scoping data to one tenant, building or occupant beyond "is this a valid token" is your module's own concern once the caller is verified — this page stops at authentication.
- Your backend is not deployed by any sandbox command. It runs on your machine and reaches the sandbox's tv-api over the network like any other client — see What to expect, and what not to.