Exemplo ponta a ponta
Um app completo em Node.js 22, só com a biblioteca padrão (node:http, node:crypto e fetch).
Ele faz tudo o que o guia descreve:
| Rota do app | Passo do guia |
|---|---|
GET /botzetta/instalar | URL de instalação: PKCE, state e redirecionamento |
GET /botzetta/callback | Callback: confere o state, troca o código e chama GET /usuarios (getUsuarios) |
POST /botzetta/webhook | Webhooks: assinatura, deduplicação e app.desinstalado |
GET /api/eu | Tela em iframe: valida o token de usuário (JWKS + GET /sessao) e chama GET /usuarios/{id} (getUsuariosId) com o token da empresa |
GET /* | A página do iframe, com frame-ancestors e o protocolo frame/1 |
Pendências de instalação, tokens e eventos ficam em memória e se perdem ao reiniciar. Em produção, guarde os tokens no seu banco, cifrados, e processe os webhooks numa fila.
Rodar
-
Copie os arquivos abaixo mantendo as pastas:
meuapp/├── .env (a partir do .env.exemplo)├── botzetta.mjs├── servidor.mjs├── public/│ ├── index.html│ ├── app.js│ └── estilo.css└── testes/└── botzetta.test.mjs -
Exponha a porta 4300 com um túnel
https(ex.:https://meuapp.exemplo.com). -
No portal (Cadastrar o app), com o slug do
.env:- Cadastro: URL de redirecionamento
https://meuapp.exemplo.com/botzetta/callback, URL de instalaçãohttps://meuapp.exemplo.com/botzetta/instalar, URL do webhookhttps://meuapp.exemplo.com/botzetta/webhook. Copie o segredo do webhook; - Rascunho: URL base do app
https://meuapp.exemplo.com, escopousuarios:lere um item de menuembutidocom a rotainicio; - Credenciais: gere o
client_secret; - Versões: publique
0.1.0e ative.
- Cadastro: URL de redirecionamento
-
Preencha o
.enve rode:node --env-file=.env servidor.mjs -
No portal, clique em Testar na sandbox. Entre no Botzetta com o seu usuário da sandbox e autorize. O terminal mostra:
[instalação] empresa 88, instalação 412, escopos usuarios:ler[api] GET /usuarios → 1 usuário(s) na empresa 88[webhook] app.instalado instalacao#412 empresa 88 (verificado) -
Abra o item do app no menu do Botzetta: a página mostra "Olá, <seu nome>", validada pelo backend.
-
Em Webhooks e logs, clique em Enviar evento de teste: chega um
webhook.testeverificado.
Os testes rodam sem rede: node --test "testes/*.test.mjs".
.env.exemplo
# Copie para .env e preencha. Rodar: node --env-file=.env servidor.mjs
# API da Plataforma (sem /plataforma/v1).
BOTZETTA_API_URL=https://api.botzetta.com
# Origem do Botzetta que abre a tela do app (portal › Sandbox). Várias: separe por vírgula.
BOTZETTA_HOST_URL=https://app2.botzetta.com
# Slug do app (o token "usuario" vem com aud app:<slug>).
APP_SLUG=meuapp
# Portal › app › Credenciais.
CLIENT_ID=
CLIENT_SECRET=
# Igual a uma das URLs de redirecionamento do cadastro do app.
REDIRECT_URI=https://meuapp.exemplo.com/botzetta/callback
# Segredo do webhook: aparece uma vez ao salvar a URL do webhook (Configuração › Cadastro). Rotação em Credenciais.
WEBHOOK_SEGREDO=
PORTA=4300
botzetta.mjs
Funções de integração: PKCE, tokens, chamadas à API, assinatura de webhook e verificação do JWT.
/**
* Integração com a Plataforma Botzetta em HTTP puro (Node 22+, sem dependências).
* Exemplo do guia "Criar Apps" (https://developers.botzetta.com/docs/apps/exemplo/).
*
* - PKCE e state da instalação (OAuth authorization code);
* - troca e renovação de tokens em POST /plataforma/v1/oauth/token;
* - verificação da assinatura dos webhooks (X-Botzetta-Assinatura);
* - validação do token "usuario" que a tela em iframe recebe do Botzetta (JWKS, RS256).
*/
import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto";
const PREFIXO = "/plataforma/v1";
const semBarraFinal = url => url.replace(/\/+$/, "");
/** Erro no formato da API: { error: "ERR_…", message }. */
export class ErroBotzetta extends Error {
constructor(codigo, mensagem, status) {
super(mensagem);
this.codigo = codigo;
this.status = status;
}
}
// ---------------------------------------------------------------- Instalação (OAuth + PKCE)
/** code_challenge = BASE64URL(SHA-256(code_verifier)), método S256 (RFC 7636). */
export const challengeDe = verifier => createHash("sha256").update(verifier).digest("base64url");
export function gerarPkce() {
const verifier = randomBytes(32).toString("base64url");
return { verifier, challenge: challengeDe(verifier) };
}
export const gerarState = () => randomBytes(24).toString("base64url");
/** Link da tela de consentimento do Botzetta, que o admin da empresa abre para instalar o app. */
export function urlInstalacao({ hostUrl, clientId, redirectUri, state, challenge }) {
const q = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
state,
code_challenge: challenge,
code_challenge_method: "S256"
});
return `${semBarraFinal(hostUrl)}/apps/instalar?${q}`;
}
/**
* POST /oauth/token. `corpo` é o grant (authorization_code ou refresh_token). Devolve os tokens
* da instalação com a validade já calculada.
*/
export async function pedirToken({ apiUrl, clientId, clientSecret }, corpo) {
const resp = await fetch(`${semBarraFinal(apiUrl)}${PREFIXO}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...corpo, client_id: clientId, client_secret: clientSecret }),
signal: AbortSignal.timeout(10_000)
});
const r = await resp.json().catch(() => ({}));
if (!resp.ok) throw new ErroBotzetta(r.error ?? "ERR_OAUTH", r.message ?? `HTTP ${resp.status}`, resp.status);
return {
accessToken: r.access_token,
refreshToken: r.refresh_token,
expiraEm: Date.now() + r.expires_in * 1000,
escopos: r.escopos,
companyId: r.companyId,
instalacaoId: r.instalacaoId
};
}
export const trocarCodigo = (credenciais, { code, redirectUri, verifier }) =>
pedirToken(credenciais, { grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: verifier });
export const renovarToken = (credenciais, refreshToken) =>
pedirToken(credenciais, { grant_type: "refresh_token", refresh_token: refreshToken });
// ---------------------------------------------------------------- Chamadas à API
/**
* Chama /plataforma/v1 com o access token da instalação. Erro da API vira ErroBotzetta
* com o código (ERR_…) e o status HTTP.
*/
export async function chamarApi(apiUrl, accessToken, metodo, rota, { corpo, idempotencyKey } = {}) {
const headers = { Authorization: `Bearer ${accessToken}`, Accept: "application/json" };
if (corpo !== undefined) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const resp = await fetch(`${semBarraFinal(apiUrl)}${PREFIXO}${rota}`, {
method: metodo,
headers,
body: corpo === undefined ? undefined : JSON.stringify(corpo),
signal: AbortSignal.timeout(15_000)
});
if (resp.status === 204) return null;
const r = await resp.json().catch(() => ({}));
if (!resp.ok) throw new ErroBotzetta(r.error ?? "ERR_HTTP", r.message ?? `HTTP ${resp.status}`, resp.status);
return r;
}
// ---------------------------------------------------------------- Webhooks
export const TOLERANCIA_WEBHOOK_SEGUNDOS = 300;
/**
* Confere X-Botzetta-Assinatura (`t=<unix>,v1=<hex>[,v1=<hex>]`) contra o corpo CRU
* e devolve o evento. Na rotação do segredo chegam dois v1: basta um conferir.
*/
export function verificarAssinatura({ corpo, cabecalho, segredo, agora = Math.floor(Date.now() / 1000) }) {
const invalida = motivo => new ErroBotzetta("ERR_WEBHOOK_ASSINATURA_INVALIDA", motivo, 401);
if (!cabecalho) throw invalida("cabeçalho ausente");
if (!segredo) throw invalida("segredo do webhook não configurado");
let t = null;
const assinaturas = [];
for (const parte of String(cabecalho).split(",")) {
const i = parte.indexOf("=");
if (i <= 0) throw invalida("cabeçalho malformado");
const chave = parte.slice(0, i).trim();
const valor = parte.slice(i + 1).trim();
if (chave === "t" && t === null) t = valor;
if (chave === "v1") assinaturas.push(valor);
}
if (!/^\d{1,12}$/.test(t ?? "")) throw invalida("t ausente ou inválido");
if (Math.abs(agora - Number(t)) > TOLERANCIA_WEBHOOK_SEGUNDOS) throw invalida("fora da janela de 5 minutos");
const esperado = createHmac("sha256", segredo).update(`${t}.`).update(corpo).digest();
const confere = assinaturas.some(v => /^[0-9a-f]{64}$/.test(v) && timingSafeEqual(Buffer.from(v, "hex"), esperado));
if (!confere) throw invalida("assinatura não confere");
// 400, não 500: um 5xx faria o Botzetta tentar de novo um corpo que nunca vai mudar.
let evento;
try {
evento = JSON.parse(Buffer.from(corpo).toString("utf8"));
} catch {
throw new ErroBotzetta("ERR_WEBHOOK_CORPO_INVALIDO", "corpo não é JSON", 400);
}
if (typeof evento?.id !== "string" || typeof evento.evento !== "string" || typeof evento.companyId !== "number") {
throw new ErroBotzetta("ERR_WEBHOOK_CORPO_INVALIDO", "evento sem id, evento ou companyId", 400);
}
return evento;
}
// ---------------------------------------------------------------- Token "usuario" (tela em iframe)
export const EMISSOR = "botzetta-nucleo";
const decodificar = parte => JSON.parse(Buffer.from(parte, "base64url").toString("utf8"));
/**
* Valida JWTs RS256 do Botzetta pelo JWKS público (GET /plataforma/v1/.well-known/jwks.json).
* As chaves ficam 10 minutos em cache. Um `kid` desconhecido força uma nova busca (rotação de chave),
* no máximo uma a cada 30 s: um token forjado com `kid` aleatório não vira uma busca por requisição.
*/
export function criarVerificadorJwt({ apiUrl, audiencia, buscar = fetch }) {
const jwksUrl = `${semBarraFinal(apiUrl)}${PREFIXO}/.well-known/jwks.json`;
let chaves = new Map();
let buscadoEm = 0;
let emAndamento = null;
function carregar() {
emAndamento ??= (async () => {
try {
const resp = await buscar(jwksUrl, { signal: AbortSignal.timeout(5_000) });
if (!resp.ok) throw new Error(`JWKS respondeu ${resp.status}`);
const { keys } = await resp.json();
chaves = new Map(keys.filter(k => k.kty === "RSA" && k.kid).map(k => [k.kid, createPublicKey({ key: k, format: "jwk" })]));
} catch (e) {
// Sem chaves em cache não há como validar; com cache, segue com as chaves que já tinha.
if (chaves.size === 0) throw new ErroBotzetta("ERR_JWKS_INDISPONIVEL", e.message, 503);
} finally {
buscadoEm = Date.now();
emAndamento = null;
}
})();
return emAndamento;
}
return async function verificarJwt(token, agora = Math.floor(Date.now() / 1000)) {
const invalido = motivo => new ErroBotzetta("ERR_TOKEN_INVALIDO", motivo, 401);
const partes = String(token ?? "").split(".");
if (partes.length !== 3) throw invalido("token malformado");
let cabecalho;
let payload;
try {
cabecalho = decodificar(partes[0]);
payload = decodificar(partes[1]);
} catch {
throw invalido("token malformado");
}
if (cabecalho.alg !== "RS256" || typeof cabecalho.kid !== "string") throw invalido("algoritmo ou kid não aceitos");
const desde = Date.now() - buscadoEm;
if (desde > 10 * 60_000 || (!chaves.has(cabecalho.kid) && desde > 30_000)) await carregar();
const chave = chaves.get(cabecalho.kid);
if (!chave) throw invalido("kid desconhecido");
const assinado = Buffer.from(`${partes[0]}.${partes[1]}`);
if (!verify("RSA-SHA256", assinado, chave, Buffer.from(partes[2], "base64url"))) throw invalido("assinatura inválida");
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (payload.iss !== EMISSOR || !aud.includes(audiencia)) throw invalido("emissor ou audiência inválidos");
if (typeof payload.exp !== "number" || payload.exp <= agora) throw invalido("token expirado");
if (typeof payload.nbf === "number" && payload.nbf > agora + 30) throw invalido("token ainda não vale");
return payload;
};
}
/**
* GET /sessao: perfil, acessos e nível de bloqueio atuais do usuário (o token só traz a identidade).
* 401/403 são do usuário; 429 e 5xx viram 503 (o Botzetta não respondeu, não é culpa do usuário).
*/
export async function obterSessao(apiUrl, tokenUsuario) {
const resp = await fetch(`${semBarraFinal(apiUrl)}${PREFIXO}/sessao`, {
headers: { Authorization: `Bearer ${tokenUsuario}`, Accept: "application/json" },
signal: AbortSignal.timeout(5_000)
}).catch(e => {
throw new ErroBotzetta("ERR_NUCLEO_INDISPONIVEL", e.message, 503);
});
const r = await resp.json().catch(() => ({}));
if (resp.status === 401 || resp.status === 403) throw new ErroBotzetta(r.error ?? "ERR_TOKEN_INVALIDO", r.message ?? "sessão inválida", resp.status);
if (!resp.ok) throw new ErroBotzetta("ERR_NUCLEO_INDISPONIVEL", `/sessao respondeu ${resp.status}`, 503);
return r;
}
servidor.mjs
/**
* Backend de um app de parceiro em HTTP puro (Node 22+, sem dependências).
*
* GET /botzetta/instalar URL de instalação: gera PKCE + state e manda o admin ao consentimento
* GET /botzetta/callback redirect URI: confere o state e troca o código pelos tokens da empresa
* POST /botzetta/webhook recebe os eventos assinados
* GET /api/eu API da tela em iframe: valida o token "usuario" e chama a Plataforma
* GET /* a página (public/), com frame-ancestors do Botzetta
*
* EXEMPLO: pendências, tokens, sessões e eventos vistos ficam em memória. Em produção, use o seu
* banco (tokens cifrados), uma fila para processar os webhooks e limite de requisições por IP.
*
* Rodar: node --env-file=.env servidor.mjs
*/
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
ErroBotzetta,
chamarApi,
criarVerificadorJwt,
gerarPkce,
gerarState,
obterSessao,
renovarToken,
trocarCodigo,
urlInstalacao,
verificarAssinatura
} from "./botzetta.mjs";
const env = process.env;
for (const nome of ["BOTZETTA_API_URL", "BOTZETTA_HOST_URL", "APP_SLUG", "CLIENT_ID", "CLIENT_SECRET", "REDIRECT_URI", "WEBHOOK_SEGREDO"]) {
if (!env[nome]) throw new Error(`Configure ${nome} no .env`);
}
const API = env.BOTZETTA_API_URL;
// A primeira origem é para onde a instalação redireciona; todas podem enquadrar a página.
const ORIGENS_HOST = env.BOTZETTA_HOST_URL.split(",").map(o => new URL(o.trim()).origin);
const credenciais = { apiUrl: API, clientId: env.CLIENT_ID, clientSecret: env.CLIENT_SECRET };
const PUBLICO = fileURLToPath(new URL("./public/", import.meta.url));
// ---------------------------------------------------------------- "Banco" (memória)
const pendentes = new Map(); // state → { verifier, expira }
const tokens = new Map(); // companyId → tokens da instalação + instaladoEm
const eventosVistos = new Map(); // id do evento → quando chegou
const renovando = new Map(); // companyId → Promise (uma renovação por vez)
const sessoes = new Map(); // sha256(token usuario) → { sessao, ate }
const DEDUPE_MS = 8 * 24 * 3600_000; // reenvio e reativação cobrem até 7 dias
/**
* Access token válido da empresa. O refresh é rotativo: o par novo é salvo ANTES de ser usado,
* e só uma renovação por empresa roda por vez (duas com o mesmo refresh revogam a instalação).
*/
async function tokenDaEmpresa(companyId, { forcar = false } = {}) {
const t = tokens.get(companyId);
if (!t) throw new ErroBotzetta("ERR_NAO_INSTALADO", "O app não está instalado nesta empresa.", 409);
if (!forcar && t.expiraEm - Date.now() > 60_000) return t.accessToken;
if (!renovando.has(companyId)) {
const p = renovarToken(credenciais, t.refreshToken)
.then(novo => {
tokens.set(companyId, { ...novo, instaladoEm: t.instaladoEm }); // salve no banco antes de usar
return novo.accessToken;
})
.catch(e => {
// Refresh revogado, expirado ou reusado: só uma nova instalação resolve.
if (e.codigo === "ERR_OAUTH_REFRESH_INVALIDO") {
tokens.delete(companyId);
throw new ErroBotzetta("ERR_NAO_INSTALADO", "A instalação perdeu a validade. Instale o app de novo.", 409);
}
throw e;
})
.finally(() => renovando.delete(companyId));
renovando.set(companyId, p);
}
return renovando.get(companyId);
}
/** Chama a API pela empresa. Um 401 renova o token e repete uma vez. */
async function chamarComoEmpresa(companyId, metodo, rota, opcoes) {
try {
return await chamarApi(API, await tokenDaEmpresa(companyId), metodo, rota, opcoes);
} catch (e) {
if (e.status !== 401) throw e;
return chamarApi(API, await tokenDaEmpresa(companyId, { forcar: true }), metodo, rota, opcoes);
}
}
// ---------------------------------------------------------------- HTTP
const enviar = (res, status, corpo, headers = {}) => {
const json = typeof corpo !== "string";
res.writeHead(status, { "Content-Type": json ? "application/json" : "text/plain; charset=utf-8", ...headers });
res.end(json ? JSON.stringify(corpo) : corpo);
};
async function lerCorpoCru(req, limite = 64 * 1024) {
const partes = [];
let total = 0;
for await (const parte of req) {
total += parte.length;
if (total > limite) throw new ErroBotzetta("ERR_CORPO_GRANDE", "corpo grande demais", 413);
partes.push(parte);
}
return Buffer.concat(partes);
}
const cookie = (req, nome) =>
(req.headers.cookie ?? "")
.split(";")
.map(c => c.trim().split("="))
.find(([k]) => k === nome)?.[1];
const seguro = env.REDIRECT_URI.startsWith("https:") ? "; Secure" : "";
function instalar(_req, res) {
const agora = Date.now();
for (const [s, p] of pendentes) if (p.expira < agora) pendentes.delete(s);
if (pendentes.size >= 1000) return enviar(res, 503, "Muitas instalações em andamento. Tente de novo em instantes.");
const { verifier, challenge } = gerarPkce();
const state = gerarState();
pendentes.set(state, { verifier, expira: agora + 10 * 60_000 });
// O state também vai num cookie: o callback só aceita o state que saiu deste navegador (anti-CSRF).
res.writeHead(302, {
Location: urlInstalacao({ hostUrl: ORIGENS_HOST[0], clientId: env.CLIENT_ID, redirectUri: env.REDIRECT_URI, state, challenge }),
"Set-Cookie": `bz_state=${state}; HttpOnly; SameSite=Lax; Path=/botzetta; Max-Age=600${seguro}`,
"Cache-Control": "no-store"
});
res.end();
}
async function callback(req, res, url) {
// A URL traz o código: nada de cache nem Referer, e o cookie do state é apagado.
const headers = {
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"Set-Cookie": `bz_state=; HttpOnly; SameSite=Lax; Path=/botzetta; Max-Age=0${seguro}`
};
const state = url.searchParams.get("state") ?? "";
const pendente = pendentes.get(state);
pendentes.delete(state);
if (!pendente || pendente.expira < Date.now() || cookie(req, "bz_state") !== state) {
return enviar(res, 400, "Instalação inválida ou expirada. Comece de novo.", headers);
}
if (url.searchParams.get("error")) return enviar(res, 200, "Instalação cancelada.", headers);
const t = await trocarCodigo(credenciais, { code: url.searchParams.get("code") ?? "", redirectUri: env.REDIRECT_URI, verifier: pendente.verifier });
tokens.set(t.companyId, { ...t, instaladoEm: Date.now() }); // em produção: no seu banco, cifrado
console.log(`[instalação] empresa ${t.companyId}, instalação ${t.instalacaoId}, escopos ${t.escopos.join(" ")}`);
// Primeira chamada à API com o token da empresa (exige o escopo usuarios:ler).
const { dados } = await chamarComoEmpresa(t.companyId, "GET", "/usuarios");
console.log(`[api] GET /usuarios → ${dados.length} usuário(s) na empresa ${t.companyId}`);
enviar(res, 200, `App instalado na empresa ${t.companyId}. Você já pode fechar esta janela.`, headers);
}
async function webhook(req, res) {
const corpo = await lerCorpoCru(req);
const evento = verificarAssinatura({ corpo, cabecalho: req.headers["x-botzetta-assinatura"], segredo: env.WEBHOOK_SEGREDO });
// Entrega "pelo menos uma vez": ignore o que já viu (pelo id, igual em todas as tentativas).
const agora = Date.now();
for (const [id, em] of eventosVistos) if (agora - em > DEDUPE_MS) eventosVistos.delete(id);
if (eventosVistos.has(evento.id)) return enviar(res, 204, "");
eventosVistos.set(evento.id, agora); // em produção: grave junto com o evento na sua fila
console.log(`[webhook] ${evento.evento} ${evento.recurso?.tipo}#${evento.recurso?.id} empresa ${evento.companyId} (verificado)`);
// Responda 2xx logo (até 10 s) e processe depois. O corpo é só o sinal: detalhes vêm da API.
enviar(res, 204, "");
if (evento.evento === "app.desinstalado") {
// A ordem não é garantida: um desinstalado antigo não apaga uma instalação mais nova.
const t = tokens.get(evento.companyId);
if (t && Date.parse(evento.ocorridoEm) >= t.instaladoEm) tokens.delete(evento.companyId); // e apague os dados da empresa (até 30 dias)
}
}
const verificarUsuario = criarVerificadorJwt({ apiUrl: API, audiencia: `app:${env.APP_SLUG}` });
/**
* Autentica a requisição da página: JWT do usuário + GET /sessao (cache de 30 s por token).
* Devolve a sessão atual; a empresa é SEMPRE a do token validado, nunca uma vinda do navegador.
*/
async function autenticar(req) {
const [tipo, token] = (req.headers.authorization ?? "").split(" ");
if (tipo !== "Bearer" || !token) throw new ErroBotzetta("ERR_TOKEN_INVALIDO", "sem token", 401);
const payload = await verificarUsuario(token);
if (payload.tipo !== "usuario") throw new ErroBotzetta("ERR_TOKEN_INVALIDO", "token não é de usuário", 401);
const chave = createHash("sha256").update(token).digest("base64url");
let sessao = sessoes.get(chave)?.ate > Date.now() ? sessoes.get(chave).sessao : null;
if (!sessao) {
sessao = await obterSessao(API, token);
if (sessoes.size > 5000) sessoes.clear();
sessoes.set(chave, { sessao, ate: Date.now() + 30_000 });
}
if (sessao.companyId !== Number(payload.cid) || sessao.usuarioId !== Number(payload.sub)) {
throw new ErroBotzetta("ERR_TOKEN_INVALIDO", "sessão não confere", 401);
}
return sessao;
}
/** Autorização no backend: o menu esconder um item não protege a rota. */
function exigir(sessao, { soAdmin = false, acesso = null, escrita = false } = {}) {
if (soAdmin && sessao.perfil !== "admin") throw new ErroBotzetta("ERR_SEM_ACESSO", "só o administrador da empresa", 403);
if (acesso && !sessao.acessos.includes("*") && !sessao.acessos.includes(acesso)) throw new ErroBotzetta("ERR_SEM_ACESSO", "sem acesso", 403);
if (escrita && sessao.nivel === "paywall") throw new ErroBotzetta("ERR_ASSINATURA_VENCIDA", "assinatura vencida", 403);
if (escrita && sessao.nivel === "total") throw new ErroBotzetta("ERR_ASSINATURA_BLOQUEADA", "assinatura bloqueada", 403);
}
async function eu(req, res) {
const sessao = await autenticar(req);
exigir(sessao); // ex.: exigir(sessao, { acesso: "meuapp.notas.view" }) ou { escrita: true } num POST
let usuario;
try {
usuario = await chamarComoEmpresa(sessao.companyId, "GET", `/usuarios/${sessao.usuarioId}`);
} catch (e) {
// Erro do token DA EMPRESA não é erro do usuário: não devolva 401 para a página.
if (e.status === 401 || e.status === 403) throw new ErroBotzetta("ERR_INSTALACAO_INDISPONIVEL", "O app não está disponível nesta empresa.", 409);
throw e;
}
enviar(res, 200, { empresa: sessao.companyId, usuario, perfil: sessao.perfil });
}
const TIPOS = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8" };
const CSP_PAGINA = `default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors ${ORIGENS_HOST.join(" ")}`;
async function pagina(res, caminho) {
const arquivo = ["/app.js", "/estilo.css"].includes(caminho) ? caminho.slice(1) : "index.html"; // deep link → index.html
let conteudo = await readFile(join(PUBLICO, arquivo));
if (arquivo === "index.html") conteudo = conteudo.toString("utf8").replaceAll("{{ORIGENS_HOST}}", () => ORIGENS_HOST.join(","));
// frame-ancestors: só o Botzetta pode enquadrar estas páginas (clickjacking).
res.writeHead(200, { "Content-Type": TIPOS[extname(arquivo)], "Content-Security-Policy": CSP_PAGINA, "X-Content-Type-Options": "nosniff" });
res.end(conteudo);
}
const servidor = createServer(async (req, res) => {
let caminho = "?";
try {
const url = new URL(req.url, "http://localhost");
caminho = url.pathname;
if (req.method === "GET" && caminho === "/botzetta/instalar") return instalar(req, res);
if (req.method === "GET" && caminho === "/botzetta/callback") return await callback(req, res, url);
if (req.method === "POST" && caminho === "/botzetta/webhook") return await webhook(req, res);
if (req.method === "GET" && caminho === "/api/eu") return await eu(req, res);
if (req.method === "GET") return await pagina(res, caminho);
enviar(res, 405, { error: "ERR_METODO", message: "método não aceito" });
} catch (e) {
const conhecido = e instanceof ErroBotzetta && e.status >= 400 && e.status < 600 && e.status !== 500;
const status = e instanceof TypeError && caminho === "?" ? 400 : conhecido ? e.status : 500;
console.error(`[erro] ${req.method} ${caminho}: ${e.codigo ?? ""} ${e.message}`);
if (res.headersSent) return;
// Mensagem só para erros nossos; o resto vira "erro interno" (não vaze detalhes da API).
const corpo = conhecido ? { error: e.codigo, message: e.message } : { error: status === 400 ? "ERR_REQUISICAO" : "ERR_INTERNO", message: "erro" };
enviar(res, status, corpo, e.codigo === "ERR_CORPO_GRANDE" ? { Connection: "close" } : {});
if (e.codigo === "ERR_CORPO_GRANDE") req.destroy();
}
});
process.on("unhandledRejection", e => console.error("[erro] promessa rejeitada sem tratamento:", e));
servidor.listen(Number(env.PORTA ?? 4300), () => console.log(`App de parceiro em http://localhost:${env.PORTA ?? 4300}`));
public/index.html e public/app.js
A página que abre no iframe. O servidor preenche o data-origens-host com as origens do Botzetta.
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meu App</title>
<link rel="stylesheet" href="/estilo.css" />
</head>
<body>
<main id="app">Conectando ao Botzetta…</main>
<!-- O servidor preenche data-origens-host com as origens do Botzetta (as mesmas do frame-ancestors). -->
<script type="module" src="/app.js" data-origens-host="{{ORIGENS_HOST}}"></script>
</body>
</html>
// Página do app dentro do iframe do Botzetta: protocolo "frame/1" por postMessage, sem bibliotecas.
const PROTOCOLO = "frame/1";
const ORIGENS_HOST = document.querySelector("script[data-origens-host]").dataset.origensHost.split(",");
const raiz = document.getElementById("app");
let origemHost = null; // fixada no handshake: daí em diante, só esta origem
// Sem contexto em 10 s, a página não está dentro do Botzetta (ou a origem não confere).
const semHandshake = setTimeout(() => mostrarErro(new Error("O Botzetta não respondeu. Recarregue a página.")), 10_000);
let contexto = null;
let seq = 0;
const pendentes = new Map();
function enviar(msg) {
window.parent.postMessage({ botzetta: PROTOCOLO, ...msg }, origemHost);
}
/** Pede algo ao host (toast, navegar, token…) e espera a resposta com o mesmo id. */
function pedir(metodo, args) {
const id = `p${++seq}`;
return new Promise((resolver, rejeitar) => {
// "confirmar" espera o usuário decidir: sem timeout.
const timer =
metodo === "confirmar"
? undefined
: setTimeout(() => {
pendentes.delete(id);
rejeitar(new Error(`o host não respondeu a ${metodo}`));
}, 30_000);
pendentes.set(id, { resolver, rejeitar, timer });
enviar({ tipo: "pedido", id, dados: { metodo, args } });
});
}
/** Token "usuario" válido: o host renova sozinho (evento "token"); perto de expirar, peça um novo. */
async function token() {
if (Date.parse(contexto.expiraEm) - Date.now() < 30_000) Object.assign(contexto, await pedir("token"));
return contexto.token;
}
const tema = t => document.body.classList.toggle("escuro", t === "escuro");
window.addEventListener("message", e => {
// Só o window.parent, numa origem permitida, e depois do handshake sempre a mesma.
if (e.source !== window.parent || !ORIGENS_HOST.includes(e.origin)) return;
if (origemHost && e.origin !== origemHost) return;
const msg = e.data;
if (!msg || msg.botzetta !== PROTOCOLO) return;
if (msg.tipo === "contexto" && !contexto) {
clearTimeout(semHandshake);
if (msg.dados?.protocolo !== PROTOCOLO || typeof msg.dados.token !== "string") return mostrarErro(new Error("Protocolo do Botzetta não suportado."));
origemHost = e.origin;
contexto = msg.dados; // { token, expiraEm, app, empresa, usuario, tema, rota, extensao? }
tema(contexto.tema);
iniciar().catch(mostrarErro);
} else if (msg.tipo === "resposta" && pendentes.has(msg.id)) {
const p = pendentes.get(msg.id);
pendentes.delete(msg.id);
clearTimeout(p.timer);
msg.dados?.ok ? p.resolver(msg.dados.valor) : p.rejeitar(new Error(`${msg.dados?.erro?.codigo}: ${msg.dados?.erro?.mensagem}`));
} else if (msg.tipo === "evento" && contexto) {
if (msg.dados?.nome === "tema") tema(msg.dados.valor);
if (msg.dados?.nome === "token" && typeof msg.dados.valor?.token === "string") Object.assign(contexto, msg.dados.valor); // { token, expiraEm }
}
});
// Handshake: avisa que está pronta. Cada postMessage só chega se a origem bater com a do host.
for (const origem of ORIGENS_HOST) window.parent.postMessage({ botzetta: PROTOCOLO, tipo: "pronto", dados: { versaoSdk: "http-puro" } }, origem);
function mostrarErro(e) {
const p = document.createElement("p");
p.className = "erro";
p.textContent = String(e.message ?? e);
raiz.append(p);
}
function botao(texto, acao) {
const b = document.createElement("button");
b.textContent = texto;
b.onclick = () => Promise.resolve().then(acao).catch(mostrarErro);
return b;
}
async function iniciar() {
// O backend valida o token e usa a empresa DELE (nunca a que a página mandar).
const r = await fetch("/api/eu", { headers: { Authorization: `Bearer ${await token()}` } });
if (!r.ok) throw new Error(`API do app respondeu ${r.status}`);
const eu = await r.json();
const titulo = document.createElement("h2");
titulo.textContent = `Olá, ${eu.usuario.nome}`;
const detalhe = document.createElement("p");
detalhe.textContent = `Empresa ${contexto.empresa.nome} (#${eu.empresa}) · perfil ${eu.perfil} · rota "${contexto.rota}"`;
raiz.replaceChildren(
titulo,
detalhe,
botao("Toast", () => pedir("toast", { mensagem: "Tudo certo por aqui", tipo: "sucesso" })),
botao("Confirmar", async () => {
const ok = await pedir("confirmar", { titulo: "Continuar?", mensagem: "Exemplo de confirmação do Botzetta." });
await pedir("toast", { mensagem: ok ? "Confirmado" : "Cancelado", tipo: "info" });
}),
botao("Ir para Pessoas", () => pedir("navegar", { caminho: "/pessoas" })),
botao("Mudar a rota", () => pedir("informarRota", { caminho: `inicio/${Date.now() % 1000}` }))
);
}
O public/estilo.css é opcional (cores do tema claro e escuro).
testes/botzetta.test.mjs
// Testes do exemplo (node --test): PKCE, assinatura de webhook e validação do token "usuario".
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac, generateKeyPairSync, sign } from "node:crypto";
import { challengeDe, criarVerificadorJwt, gerarPkce, urlInstalacao, verificarAssinatura } from "../botzetta.mjs";
test("PKCE S256: challenge = base64url(sha256(verifier)), sem padding", async () => {
// Cálculo independente pelo Web Crypto, com base64 convertido à mão para base64url.
const v = "verificador-de-teste-com-43-caracteres-ou-mais";
const digest = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)));
assert.equal(challengeDe(v), digest.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
const { verifier, challenge } = gerarPkce();
assert.match(verifier, /^[A-Za-z0-9_-]{43}$/);
assert.equal(challenge, challengeDe(verifier));
});
test("URL de instalação aponta para /apps/instalar com PKCE S256", () => {
const u = new URL(urlInstalacao({ hostUrl: "https://app.exemplo.com/", clientId: "bzt_x", redirectUri: "https://a.exemplo.com/cb", state: "s1", challenge: "c1" }));
assert.equal(u.origin + u.pathname, "https://app.exemplo.com/apps/instalar");
assert.deepEqual(Object.fromEntries(u.searchParams), {
client_id: "bzt_x",
redirect_uri: "https://a.exemplo.com/cb",
response_type: "code",
state: "s1",
code_challenge: "c1",
code_challenge_method: "S256"
});
});
const SEGREDO = "whsec_teste";
const corpo = Buffer.from(JSON.stringify({ id: "evt_1", evento: "app.instalado", versao: "1", companyId: 9, recurso: { tipo: "instalacao", id: 3 } }));
const assinar = (t, segredo = SEGREDO, c = corpo) => createHmac("sha256", segredo).update(`${t}.`).update(c).digest("hex");
test("webhook: assinatura válida devolve o evento", () => {
const t = 1_800_000_000;
const ev = verificarAssinatura({ corpo, cabecalho: `t=${t},v1=${assinar(t)}`, segredo: SEGREDO, agora: t + 10 });
assert.equal(ev.id, "evt_1");
});
test("webhook: na rotação, qualquer um dos dois v1 vale", () => {
const t = 1_800_000_000;
const cabecalho = `t=${t},v1=${assinar(t, "whsec_novo")},v1=${assinar(t)}`;
assert.equal(verificarAssinatura({ corpo, cabecalho, segredo: SEGREDO, agora: t }).evento, "app.instalado");
});
test("webhook: recusa corpo alterado, segredo errado e t fora da janela", () => {
const t = 1_800_000_000;
const cab = `t=${t},v1=${assinar(t)}`;
const recusa = o => assert.throws(() => verificarAssinatura({ corpo, cabecalho: cab, segredo: SEGREDO, agora: t, ...o }), { codigo: "ERR_WEBHOOK_ASSINATURA_INVALIDA" });
recusa({ corpo: Buffer.from(corpo.toString().replace("9", "8")) });
recusa({ segredo: "outro" });
recusa({ agora: t + 301 });
recusa({ cabecalho: undefined });
recusa({ cabecalho: `v1=${assinar(t)}` });
});
// JWT RS256 assinado por uma chave de teste, servida por um JWKS falso.
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: "jwk" }), kid: "k1", alg: "RS256", use: "sig" };
const b64 = o => Buffer.from(JSON.stringify(o)).toString("base64url");
function jwt(payload, cab = { alg: "RS256", kid: "k1", typ: "JWT" }) {
const dados = `${b64(cab)}.${b64(payload)}`;
return `${dados}.${sign("RSA-SHA256", Buffer.from(dados), privateKey).toString("base64url")}`;
}
const buscar = async () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
const agora = Math.floor(Date.now() / 1000);
const base = { iss: "botzetta-nucleo", aud: "app:meuapp", sub: "7", cid: 3, tipo: "usuario", exp: agora + 600 };
test("token usuario: válido passa; audiência, emissor, validade e assinatura são conferidos", async () => {
const v = criarVerificadorJwt({ apiUrl: "https://api.exemplo.com", audiencia: "app:meuapp", buscar });
assert.equal((await v(jwt(base))).cid, 3);
await assert.rejects(v(jwt({ ...base, aud: "app:outro" })), { codigo: "ERR_TOKEN_INVALIDO" });
await assert.rejects(v(jwt({ ...base, iss: "outro" })), { codigo: "ERR_TOKEN_INVALIDO" });
await assert.rejects(v(jwt({ ...base, exp: agora - 1 })), { codigo: "ERR_TOKEN_INVALIDO" });
await assert.rejects(v(jwt(base, { alg: "none", kid: "k1" })), { codigo: "ERR_TOKEN_INVALIDO" });
const [c, , assinatura] = jwt(base).split(".");
await assert.rejects(v(`${c}.${b64({ ...base, cid: 4 })}.${assinatura}`), { codigo: "ERR_TOKEN_INVALIDO" });
});
test("webhook: corpo assinado que não é JSON vira 400 (não 5xx)", () => {
const t = 1_800_000_000;
const c = Buffer.from("não é json");
assert.throws(() => verificarAssinatura({ corpo: c, cabecalho: `t=${t},v1=${assinar(t, SEGREDO, c)}`, segredo: SEGREDO, agora: t }), {
codigo: "ERR_WEBHOOK_CORPO_INVALIDO",
status: 400
});
});
test("token usuario: kid desconhecido não busca o JWKS de novo antes de 30 s", async () => {
let buscas = 0;
const contar = async () => {
buscas++;
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
};
const v = criarVerificadorJwt({ apiUrl: "https://api.exemplo.com", audiencia: "app:meuapp", buscar: contar });
await v(jwt(base));
for (let i = 0; i < 5; i++) await assert.rejects(v(jwt(base, { alg: "RS256", kid: `forjado-${i}` })), { codigo: "ERR_TOKEN_INVALIDO" });
await assert.rejects(v(jwt(base, { alg: "RS256" })), { codigo: "ERR_TOKEN_INVALIDO" });
assert.equal(buscas, 1);
});