Ir para o conteúdo

Painel admin

Um painel de gerenciamento server-rendered montado sob /admin, no estilo do Django admin. O operador entra com uma linha do seu próprio banco — não há store de senha separado —, e cada model registrado passa a ser navegável pelo navegador: dashboard, listagem com busca/filtro/ordenação, e formulários de criar/editar/excluir derivados das colunas.

O que você ganha:

  • 🔐 Login por sessão em cookie assinado, com CSRF em toda escrita.
  • 📊 Dashboard com contagem de linhas por model e painel de CPU/memória.
  • 🔎 List view com busca, filtros por tipo de coluna e colunas ordenáveis.
  • ✍️ CRUD com widget derivado do tipo da coluna e erro por campo.
  • 📱 Responsivo, sem JavaScript de framework e sem asset externo.
  • 🎨 Tema tipado (AdminTheme) — cor, logo, favicon, fonte, dark mode.

Sem dependência nova

O painel roda sobre o que o SDK já tem: tempest-db-js para os models, PasswordUtils para a senha, node:crypto para assinar a sessão. O HTML e o CSS são strings deste pacote, então não há template engine para instalar nem arquivo estático para servir.

Precisa da API headless em vez das telas prontas? Veja Admin headless (API JSON).

1. O model de usuário

Estenda BaseUserModel para ganhar as colunas que o backend de auth do painel espera (email, hashedPassword, isAdmin, lastLoginAt) por cima do BaseModel:

import { BaseUserModel, column, tableNameFor } from "tempest-express-sdk";

export class UserModel extends BaseUserModel {
  static override tablename = tableNameFor("UserModel"); // "user"
  name = column.varchar(120).notNull();
}

Só linhas com isActive === true e isAdmin === true entram. O primeiro admin vem de um script de seed — o mesmo ciclo de sessão que seus repositories já usam:

import {
  BaseRepository,
  PasswordUtils,
  createEngine,
} from "tempest-express-sdk";

import { UserModel } from "./db/models";

async function main(): Promise<void> {
  const engine = createEngine(process.env.DATABASE_URL ?? "sqlite:///app.db");
  const passwords = new PasswordUtils();
  const users = new BaseRepository(UserModel, engine.session());

  await users.create({
    email: "root@example.com",
    hashedPassword: await passwords.hash("hunter2hunter2"),
    isAdmin: true,
    name: "Root",
    lastLoginAt: null,
  });

  await engine.close();
}

void main();

2. Registre seus models

AdminModel é uma instância de configuração tipada — o objeto de opções é o contrato, sem mágica de metaclasse. Os defaults já funcionam; passe os campos que quiser para enriquecer a listagem:

import { AdminModel, AdminSite } from "tempest-express-sdk";

import { OrderModel, UserModel } from "./db/models";

export const site = new AdminSite({
  title: "MyApp Admin",
  brand: "myapp-admin",          // texto centralizado no topo (default: title)
  indexSubtitle: "Site administration",
  siteUrl: "https://myapp.com",  // link "View site" opcional no header
});

site.register(
  new AdminModel({
    model: UserModel,
    listDisplay: ["email", "name", "isAdmin", "isActive", "lastLoginAt"],
    listFilter: ["isActive", "isAdmin"],
    searchFields: ["email", "name"],
    ordering: "-createdAt",
    pageSize: 25,
  }),
);

O slug sai do tablename do model, então URL e tabela ficam em sincronia. register também aceita as opções direto (site.register({ model: OrderModel })) e lança se dois models disputarem o mesmo slug.

Filtros automáticos por tipo de coluna

Cada campo em listFilter vira o widget certo conforme o tipo da coluna: boolean → dropdown Sim/Não; enum → dropdown com os membros; date/datetime → dois inputs de data (de/até); qualquer outra coluna → input de texto (igualdade). Tudo preserva busca, ordenação e paginação na URL.

Atalho: registrar tudo de uma vez (automap)

Aponte o automap para o barrel dos models e todo model concreto é registrado com os defaults. Bases abstratas (BaseModel, BaseUserModel — sem tablename) são puladas sozinhas:

import * as models from "./db/models";

site.automap(models);

Misture os dois estilos: registre à mão o que precisa de config própria, depois deixe o automap preencher o resto — por padrão ele pula slugs já registrados:

site.register(new AdminModel({ model: UserModel, searchFields: ["email"] }));
site.automap(models, { exclude: ["audit_log"], pageSize: 50 });

automap aceita um array de classes (site.automap([UserModel, OrderModel])), exclude (classe ou nome de tabela), skipRegistered: false para transformar colisão em erro, e qualquer opção de AdminModel aplicada uniformemente.

3. Monte o router

import {
  UserModelAuthBackend,
  createApp,
  createEngine,
  makeAdminRouter,
} from "tempest-express-sdk";

import { site } from "./admin/site";
import { settings } from "./core/settings";
import { UserModel } from "./db/models";

const engine = createEngine(settings.DATABASE_URL);

const app = await createApp({
  configure: (a) => {
    a.use(
      makeAdminRouter(site, {
        engine,
        authBackend: new UserModelAuthBackend(UserModel),
        secretKey: settings.JWT_SECRET,   // pelo menos 32 caracteres
        prefix: "/admin",
        cookieSecure: !settings.DEBUG,    // true em produção HTTPS
      }),
    );
  },
});

makeAdminRouter monta:

Rota O que faz
GET /admin/login · POST /admin/login Fluxo de entrada
GET /admin/mfa · POST /admin/mfa Desafio TOTP (backends com MFA)
POST /admin/logout Encerra a sessão
GET /admin/ Dashboard: contagem por model + CPU/memória
GET /admin/m/{slug} List view: busca, filtros, ordenação, paginação
POST /admin/m/{slug}/bulk Ações em massa nas linhas marcadas
GET /admin/m/{slug}/export.csv · .json Export do resultado atual
GET/POST /admin/m/{slug}/new Criar (quando canCreate)
GET /admin/m/{slug}/{id} Detalhe, com Edit/Delete
GET/POST /admin/m/{slug}/{id}/edit Editar (quando canEdit)
POST /admin/m/{slug}/{id}/delete Excluir (quando canDelete)
GET /admin/static/admin.css A folha de estilo embutida

secretKey é o que separa um operador de um invasor

A sessão é stateless: id do principal, nome, token CSRF e expiração viajam no cookie, assinados com HMAC-SHA256 sobre essa chave. Ela precisa ter no mínimo 32 caracteres (o construtor recusa menos), vir do ambiente e nunca ir para o repositório. Trocar a chave desloga todo mundo — que é exatamente o que você quer se ela vazar.

Escrita (CRUD) e permissões

Create/edit/delete são controlados por canCreate / canEdit / canDelete no AdminModel (todas true por default; uma view desligada responde 404 e some da UI). Todo POST carrega o token CSRF da sessão, validado no servidor (403 em mismatch). Os widgets são derivados do tipo da coluna — texto / textarea (string longa) / number / checkbox / datetime-local / date / time / select para enum / textarea JSON — com validação de obrigatórios e erro por campo re-renderizado no formulário. Escrita que o banco recusa (unique, FK, NOT NULL) volta pelo mesmo caminho: 400 com a mensagem no topo do form, nunca 500.

4. Segundo fator (opcional)

Um principal que habilitou TOTP passa por /admin/mfa depois da senha — o painel nunca vira a porta mais fraca de uma conta protegida por MFA. Ligue passando um verificador ao backend:

import { MfaService, TOTPHelper, UserModelAuthBackend } from "tempest-express-sdk";

const mfaService = new MfaService({ store: mfaStore, totp: new TOTPHelper() });

const authBackend = new UserModelAuthBackend(UserModel, {
  mfa: {
    isEnabled: (userId) => mfaStore.isConfirmed(userId),
    verify: (userId, code) => mfaService.verify(userId, code),
  },
});

Sem mfa, o backend declara que não tem segundo fator e toda senha correta completa o login.

5. Tema

Todo knob é um campo tipado, injetado como custom property CSS:

const site = new AdminSite({
  title: "MyApp Admin",
  theme: {
    accent: "#7c3aed",
    headerBg: "#1e1b4b",
    logoUrl: "/static/logo.svg",
    faviconUrl: "/static/favicon.ico",
    fontFamily: "'Inter', sans-serif",
    radius: "10px",
    footerText: "MyApp | 2026",
    darkMode: true,
  },
});

Para o que os campos não cobrem, aponte customCssUrl para a sua folha — ela é linkada por último e sobrescreve tudo.

Valor de tema é validado

<, >, {, } e " são recusados na construção: eles quebrariam o bloco <style> injetado. É defeito de configuração, então falha alto em vez de gerar markup corrompido.

Navegação por sidebar + burger

Toda página autenticada tem uma sidebar: Dashboard e um link por model registrado, com o item atual destacado. No desktop ela fica sempre visível; no mobile (≤768px) vira off-canvas, aberta pelo ícone burger e fechada tocando no scrim — CSS puro, sem JS.

6. Ações em massa

A list view mostra um checkbox por linha, um "selecionar tudo" e uma barra de ação que opera nas linhas marcadas. Três ações já vêm prontas — Activate, Deactivate (quando canEdit e o model tem a coluna isActive) e Delete (quando canDelete) — e cada uma volta com um banner dizendo quantas linhas mudaram.

Ações customizadas

Qualquer coisa específica do domínio — "enviar boas-vindas", "marcar como enviado", "recalcular totais" — é uma ação customizada: um handler criado com adminAction e passado em AdminModel({ actions: [...] }).

import { AdminModel, adminAction } from "tempest-express-sdk";

import { site } from "./site";
import { OrderModel } from "../db/models";
import { mailer } from "../core/email";

const markPaid = adminAction({ label: "Marcar como pago" }, async (ctx) => {
  const changed = await ctx.repository.update(
    { id: { in: ctx.ids } },
    { status: "paid" },
  );
  return { message: `${changed} pedido(s) marcado(s) como pago.` };
});

const notifyCustomers = adminAction(
  { label: "Avisar clientes", dangerous: false },
  async (ctx) => {
    const orders = await ctx.repository.list({ id: { in: ctx.ids } });
    for (const order of orders) await mailer.send(order.email, "Seu pedido");
    return { message: `${orders.length} e-mails enviados.` };
  },
);

site.register(
  new AdminModel({ model: OrderModel, actions: [markPaid, notifyCustomers] }),
);

O handler recebe um contexto com:

Campo O que é
ids Identidades das linhas marcadas.
repository BaseRepository do model, na sessão do request.
dbSession A sessão de banco, para trabalho além do repository.
request O request inbound.
session A sessão do admin autenticado.
principal A linha do operador que disparou a ação.

Retorne { message, category } para exibir um banner (category aceita "success" — o default —, "error" e "warning"), ou null para não mostrar nada. dangerous: true marca a ação como destrutiva no dropdown.

O handler continua uma função comum

O SDK Python usa um decorator @admin_action; aqui adminAction devolve o descritor. O handler fica direto chamável e testável (markPaid.handler(ctx)), sem sintaxe de decorator para ligar no build de quem consome.

O nome sai do label

O identificador submetido é o slug do label ("Marcar como pago" → marcar-como-pago), namespaced como custom:<nome> para nunca colidir com uma ação embutida. Passe name para fixá-lo — o valor vai no HTML, então mudá-lo é mudar a superfície. Dois nomes iguais no mesmo model levantam erro na construção.

Exceção no handler vira banner, não 500

Uma ação que levanta é registrada no log e volta como banner de erro na list view, com a mensagem da exceção. O operador vê o que falhou em vez de uma página de erro genérica — mas a mensagem chega ao navegador, então não coloque segredo no texto da exceção.

7. Export CSV / JSON

Os botões Export CSV e Export JSON baixam o resultado atual — mesma busca, mesmos filtros, mesma ordenação, mesmas colunas do listDisplay:

GET {prefix}/m/{slug}/export.csv?q=...&filter_status=paid&sort=createdAt&dir=desc
GET {prefix}/m/{slug}/export.json?...

O CSV segue RFC 4180 (aspas duplicadas, campo entre aspas quando tem vírgula, aspas ou quebra de linha) e o JSON é um array de objetos coluna→valor. Date vira ISO, bigint vira string decimal, binário vira base64.

O teto existe por um motivo

Export é varredura de tabela inteira transmitida para um navegador. makeAdminRouter(site, { exportMaxRows: 5000 }) limita quantas linhas saem (default 5000) — é o que impede um clique curioso numa tabela grande de virar incidente. Suba o teto conscientemente.

8. Select de chave estrangeira

Uma coluna FK cujo model de destino está registrado no mesmo site vira um <select> das linhas relacionadas, no formulário e no filtro da listagem, em vez de um campo de UUID cru. O label da opção sai do primeiro searchFields do admin referenciado; sem ele, de name / title / email / label / reference; e por último da identidade.

class OrderModel extends BaseModel {
  static override tablename = "sales_order";
  userId = column.uuid().references("user.id");
}

site.register(new AdminModel({ model: UserModel, searchFields: ["name"] }));
site.register(
  new AdminModel({ model: OrderModel, listFilter: ["userId"] }),
);

FK para tabela não registrada continua input de texto — um dropdown vazio seria pior que o campo cru. As opções são limitadas a 1000 linhas; acima disso a tabela de destino pede um campo de busca, não uma lista.

9. Controle de acesso por papel

Por padrão todo operador que entra (isAdmin) faz tudo que as flags do AdminModel permitem. Para restringir um principal a um subconjunto de models ou de ações, passe uma accessPolicy:

import { AdminPermission, makeAdminRouter } from "tempest-express-sdk";

makeAdminRouter(site, {
  engine,
  authBackend,
  secretKey: settings.JWT_SECRET,
  accessPolicy: (user, admin, action) => {
    const principal = user as { role: string };
    if (principal.role === "superadmin") return true;
    if (principal.role === "support") return action === AdminPermission.VIEW;
    return admin.slug() === "article";
  },
});

A política compõe com canCreate / canEdit / canDelete: as duas precisam permitir. E o painel não mostra porta que responde erro — model sem VIEW some da sidebar e do dashboard, ação sem permissão some do dropdown de massa, e os botões + New, Edit e Delete só aparecem quando a política deixa.

404 e 403 querem dizer coisas diferentes

Flag desligada responde 404 — aquela view não existe neste painel. Política recusando responde 403 — a view existe e este operador não pode usá-la. Misturar os dois esconderia configuração errada atrás de um "sem permissão".

10. Trilha de auditoria

Modelo com as colunas createdBy / updatedBy (via createdByColumn() / updatedByColumn()) é carimbado automaticamente com o id do operador — na criação e na edição pelo painel. O detail ganha um painel Audit com timestamps e os atores já resolvidos para nome pelo auth backend.

Para ver o quê mudou, e não só quem e quando, passe um auditModel — a mesma tabela BaseAuditLogModel que seus services já escrevem:

import { BaseAuditLogModel } from "tempest-express-sdk";

class AuditLogModel extends BaseAuditLogModel {
  static override tablename = "audit_log";
}

site.register(new AdminModel({ model: OrderModel, auditModel: AuditLogModel }));

O detail passa a mostrar uma timeline por registro (as 50 entradas mais recentes): ação, ator, quando, a tabela de diff campo a campo e o context que o escritor gravou. Cada entrada é um <details> recolhido, então histórico longo continua escaneável — e sem JavaScript.

O painel lê a trilha, não escreve

auditModel só alimenta a tela. Quem grava as linhas é o seu service, com snapshot / diffSnapshots. Registrar o model sem gravar nada deixa a timeline vazia — o painel não inventa histórico.

11. Cards de métrica no dashboard

Além do painel de CPU/memória, o dashboard aceita cards de negócio calculados do seu próprio banco no carregamento:

import { metricCard } from "tempest-express-sdk";

const site = new AdminSite({
  title: "Shop",
  dashboardCards: [
    metricCard(
      "Pedidos hoje",
      async (session) => ({
        kind: "value",
        value: await new BaseRepository(OrderModel, session).count({ ... }),
        unit: "pedidos",
      }),
      "Desde a meia-noite",
    ),
    metricCard("Semana vs anterior", async (session) => ({
      kind: "trend",
      value: 18,
      previous: 12,
    })),
    metricCard("Por status", async (session) => ({
      kind: "partition",
      segments: [
        { label: "Pagos", value: 8 },
        { label: "Pendentes", value: 6 },
      ],
    })),
  ],
});

Três formatos: value (número em destaque), trend (▲/▼ com a variação percentual contra o período anterior) e partition (barras por segmento). trendPercent devolve null quando o período anterior é zero — porcentagem contra zero é indefinida, não infinita.

Card quebrado não derruba o dashboard

Card cujo compute levanta é registrado no log e renderiza como "Could not compute this metric." — uma query ruim não custa ao operador todos os outros números da página.

12. Lenses (presets salvos de listagem)

Uma lens junta filtros e ordenação sob um rótulo, e vira aba acima da tabela:

import { adminLens } from "tempest-express-sdk";

site.register(
  new AdminModel({
    model: TicketModel,
    lenses: [
      adminLens({
        name: "Triage aberta",
        filters: { status: "open", priority: { gte: 3 } },
        orderBy: "-createdAt",
      }),
      adminLens({ name: "Fechados", filters: { status: "closed" } }),
    ],
  }),
);

Clicar numa aba aplica ?lens=<slug>. Os filtros da lens são ANDados com o que o operador digitou, então busca e filtros continuam funcionando por cima dela; a ordenação da lens vale até o operador clicar num cabeçalho de coluna. A aba All volta para a listagem sem preset, e o lens viaja nos links de paginação, ordenação e export.

13. Upload de arquivo e imagem

Uma coluna String que guarda o caminho/chave de um arquivo pode virar input de upload. Liste-a em uploadFields e passe um uploadStorage — os backends que o SDK já tem servem:

import { AdminModel, LocalUploadStorage } from "tempest-express-sdk";

site.register(
  new AdminModel({
    model: DocumentModel,
    uploadFields: ["attachment"],
    uploadStorage: new LocalUploadStorage({ root: "media/", baseUrl: "/media" }),
  }),
);
  • O formulário vira multipart/form-data sozinho quando há campo de upload.
  • Create: arquivo obrigatório só quando a coluna é NOT NULL e sem default.
  • Edit: sem arquivo novo, mantém o atual (o form mostra Current: …); com arquivo, substitui.
  • A coluna guarda a chave do storage (<slug>/<campo>/<uuid>.<ext>); use o uploadStorage depois para servir ou baixar.

busboy é peer opcional

Multipart não vem no Express. O painel usa busboy — peer opcional, só exigido por quem configura uploadFields ou canImport, e o erro diz o comando de instalação. npm install busboy. Parser de formato de fio tem cauda longa de correção (boundary, transfer encoding, escape de filename): é o caso em que se depende em vez de reimplementar.

Teto de upload

makeAdminRouter(site, { maxUploadBytes }) limita o tamanho aceito (default 10 MB). Arquivo acima do teto volta como erro no formulário, sem escrever nada. O arquivo é bufferizado em memória — é o que um painel precisa (um operador anexando um documento), não um caminho de ingestão em streaming.

uploadFields exige uploadStorage

Registrar um sem o outro levanta erro na construção do AdminModel: sem storage não há onde gravar, e falhar no boot é melhor que falhar no primeiro upload de produção.

14. Import CSV

canImport: true (junto de canCreate) expõe uma página de import que cria linhas em massa a partir de um arquivo enviado:

site.register(new AdminModel({ model: OrderModel, canImport: true }));

O arquivo é UTF-8 com header; colunas desconhecidas são ignoradas e as reconhecidas são as mesmas que o formulário edita. Cada linha passa pela mesma coerção e validação do formulário, e as que falham voltam numa tabela com o número da linha da planilha (começa em 2, porque a linha 1 é o header) e o motivo. As que passam são criadas — import parcial é resultado normal, não erro.

O parser respeita a RFC 4180

Campo entre aspas pode conter vírgula, quebra de linha e aspas duplicadas, e o BOM que o Excel escreve é removido — sem isso o primeiro nome de coluna nunca casaria. Import que estraga justamente as linhas que alguém teve o trabalho de escapar é pior que import nenhum.

15. Autocomplete de chave estrangeira

FK para tabela grande demais para caber num <select> vira caixa de busca:

site.register(
  new AdminModel({
    model: OrderModel,
    autocompleteFields: ["userId"],
  }),
);

O campo passa a buscar em GET {prefix}/m/{slug}/autocomplete/{campo}?q=, que consulta os searchFields do admin referenciado e devolve até 20 opções. Ao editar, a caixa abre já com o rótulo da linha atual, não com o UUID.

Sem CDN

O SDK Python usa HTMX de CDN aqui. Este painel usa ~30 linhas de DOM puro, porque script de terceiro num console de operador é uma requisição externa que deploy fechado não faz e CSP estrita precisa liberar — e o que se precisa é um fetch e uma lista.

16. Inlines (filhos na tela do pai)

Um Inline mostra na tela do pai as linhas filhas que apontam para ele — os itens de um pedido, as chaves de API de um usuário — sem viagem para outra tela:

import { AdminModel, adminInline } from "tempest-express-sdk";

site.register(
  new AdminModel({
    model: OrderModel,
    inlines: [
      adminInline({
        model: OrderItemModel,
        fkField: "orderId",
        label: "Itens do pedido",
        editable: true,
        canDelete: true,
      }),
      adminInline({ model: OrderNoteModel, fkField: "orderId" }),
    ],
  }),
);

Sem editable, o bloco é uma tabela somente leitura com link para o admin do filho e um botão Add. Com editable, vira um formset in-place: uma linha de inputs por filho, mais uma linha em branco para adicionar outro, tudo salvo num submit só que faz criação, edição e exclusão de uma vez.

  • Formset editável exige o model filho registrado no mesmo site e com canEdit; canDelete (no inline e no admin do filho) adiciona o checkbox de exclusão por linha.
  • A linha em branco com todos os campos vazios é ignorada — submeter o formset sem preencher o extra não cria filho vazio.
  • Linha que falha na validação volta com o que o operador digitou e o erro por campo; as outras linhas do mesmo submit são gravadas.

A FK do pai fica fora do formset

A coluna que aponta para o pai não é oferecida como campo: o pai daquela linha é a página em que ela está. Oferecê-la deixaria o operador mover um filho para outro pai digitando um UUID numa célula.

Mais forte que isso: toda linha submetida é checada como pertencente a este pai antes de ser editada ou excluída. A chave de linha vem do navegador, então uma submissão forjada poderia nomear o filho de outro pai — a checagem de posse é o que impede a página de virar superfície de edição da tabela inteira.

Limite de linhas

O bloco renderiza no máximo 50 filhos e diz quantos existem no total. Pai com mais filhos que isso quer a listagem do próprio filho com filtro, não um formset gigante na página do pai.

17. Página de logs

Passe o mesmo diretório que você deu ao configureFileLogging e o painel ganha uma página de logs:

makeAdminRouter(site, { engine, authBackend, secretKey, logDir: settings.LOG_DIR });

Ela lê os arquivos JSON estruturados, filtra por fonte (all/debug/info/warning/error/500) e por busca, e pagina — mais recente primeiro, com badge colorido por nível. Registro que carrega traceback vira um <details>: a mensagem é o gatilho, o trace e os campos de correlação (requestId, path, method, status_code) ficam dentro, recolhidos.

A busca casa mensagem, logger e traceback — quem caça um 500 costuma ter um pedaço do trace, não da mensagem.

GET {prefix}/logs/export?format=md|json baixa a seleção filtrada, no máximo 500 registros:

  • md — cada traceback num bloco cercado, então sobrevive a um colar em issue com a indentação intacta. O cabeçalho declara fonte, busca, quantos registros saíram e quantos casavam no total — export parcial nunca se passa por completo.
  • json — os registros verbatim, com todo campo que a aplicação logou.

Opt-in por um motivo

Sem logDir a página não existe (404). O payload expõe traceback e metadado de request: só habilite atrás do login do admin, e lembre que o export herda exatamente o mesmo gate.

18. SQL console

O console fica desligado por padrão. Ligue passando sqlConsole:

import { SqlCapability } from "tempest-express-sdk";

makeAdminRouter(site, {
  engine,
  authBackend,
  secretKey,
  sqlConsole: {
    dialect: "postgresql",
    policy: {
      capabilities: [SqlCapability.READ],
      allowTables: ["orders", "customers", "invoices"],
      maxRows: 200,
    },
    onAudit: (entry) => auditLogger.info("admin sql", { ...entry }),
  },
});

Leia isto antes de habilitar

Um filtro de SQL na aplicação é defesa em profundidade, não fronteira de segurança. O analisador aqui faz parse de verdade (via node-sql-parser), não match de string, e isso pega o erro comum: um DROP digitado por quem só queria SELECT, um UPDATE sem WHERE, uma consulta a uma tabela que guarda dado de cartão. Ele não para um operador determinado com tempo: SQL tem CTE, subquery, função, extensão de dialeto e truque de comentário, e toda allowlist baseada em parser é um jogo de cobertura.

A fronteira que realmente segura é o usuário do banco. Um papel com apenas SELECT em três tabelas não derruba nada, chegue o que chegar:

CREATE ROLE admin_console LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE app TO admin_console;
GRANT SELECT ON orders, customers, invoices TO admin_console;

Aponte o run do console para essa conexão e use a política para estreitar mais e para produzir uma recusa legível em vez de um erro de banco. Usadas assim, as duas camadas se complementam. Usada sozinha, a política é um quebra-molas.

Capabilities dividem o risco como o operador pensa nele, não como o SQL agrupa palavra-chave: read, insert, update, delete, ddl, drop (DROP e TRUNCATE — o que ninguém desfaz) e admin (GRANT/REVOKE/SET). Statement que o parser não entende cai em admin de propósito: construção que ninguém antecipou exige a capability mais privilegiada, não a menos.

Outras guardas: requireWhereOnWrites (default true) recusa UPDATE/DELETE sem WHERE; allowTables/denyTables restringem as tabelas — com allowlist, statement que não nomeia tabela nenhuma também é recusado, para allowTables falhar fechado; maxRows (default 200) limita o que volta para o navegador.

Toda tentativa — permitida ou recusada — vai para onAudit, com o SQL, o operador, o veredito, o motivo, a duração e a contagem de linhas. Hook que levanta é logado e engolido: trilha de auditoria que derruba o que audita acaba desligada.

19. Tasks (declarado + histórico)

O painel mostra as duas metades do trabalho em background — o que este processo declara e o que os workers registraram:

import { BaseJobModel, JobStore, TaskManager } from "tempest-express-sdk";

export class JobModel extends BaseJobModel {
  static override tablename = "job";
}

const tasks = new TaskManager();
tasks.register("nightly-export", exportOrders, {
  description: "Envia os pedidos de ontem para o armazém",
  schedule: "0 3 * * *",
});

makeAdminRouter(site, {
  engine,
  authBackend,
  secretKey,
  tasks: {
    manager: tasks,
    jobs: (session) => new JobStore(JobModel, session),
  },
});

Declarado vem do registro do TaskManager: nome, schedule e description que você passou no register. O schedule é registrado e exibido, nunca interpretado — o manager consome fila, ele não agenda.

Runs vem do JobStore: uma linha por unidade de trabalho longo, que o próprio worker escreve.

const jobs = new JobStore(JobModel, session);
const job = await jobs.enqueue("nightly-export", { day });
try {
  await jobs.start(String(job.id));
  const rows = await exportOrders(day);
  await jobs.succeed(String(job.id), { rows });
} catch (error) {
  await jobs.fail(String(job.id), error);
}

Cada transição é um método, não um update cru, então "terminou" sempre move as mesmas colunas juntas — status sem finishedAt é o tipo de linha meio escrita que faz uma tela de histórico mentir.

A página lista os runs mais recentes primeiro, filtra por status e por nome, e abre cada um com payload, resultado, erro e tentativas. Run que ainda não terminou tem botão Cancel; JobStore.cancel recusa mexer em run que já está num estado terminal e devolve false, então o operador que clica em cancel num run que acabou de terminar vê a verdade em vez de uma linha reescrita.

Qualquer uma das metades pode faltar: só manager mostra o declarado, só jobs mostra o histórico. Seção sem fonte é omitida, não renderizada vazia — tabela vazia sugere que não há nada para ver.

Profundidade de fila não é mostrada

A página responde "o que está declarado" e "o que foi persistido", nunca "quantas mensagens estão no broker agora". Broker não expõe isso, e um número que parecesse essa resposta seria pior que nenhum.

O store não é acoplado ao manager

Nem toda mensagem enfileirada merece linha durável, e o worker que escreve uma normalmente quer gravar campos de domínio que o envelope nunca carregou. Por isso JobStore é chamado pelo worker, não pelo TaskManager.

Recapitulando

  1. BaseUserModel + uma linha isAdmin semeada dá o login.
  2. AdminSite + AdminModel (ou automap) diz o que é gerenciável.
  3. makeAdminRouter(site, { engine, authBackend, secretKey }) monta o painel.
  4. Widget, filtro e validação saem das colunas do model — não há schema duplicado para manter em sincronia.
  5. actions: [...] leva a operação do dia a dia para dentro do painel; export e FK-select saem de graça do que o model já declara.
  6. accessPolicy estreita quem faz o quê; auditModel responde quem mudou o quê; dashboardCards e lenses levam para o painel os números e as consultas que o time repete todo dia.