Multi-tenancy
@basaltkit/tenancy torna cada pedido ciente do tenant: um resolver identifica o tenant a partir do pedido recebido, uma TenantSource carrega o seu registo, e o resultado vive no contexto do pedido — onde a cache, o storage, a queue, o logger e o teu client Prisma o apanham automaticamente. É desacoplado da auth e das teams: resolver um tenant responde a qual tenant diz respeito o pedido, nunca a se quem chama pode agir sobre ele.
Modelo mental
Quatro peças, pela ordem em que correm:
| Peça | Corre | Responsabilidade |
|---|---|---|
TenantResolver | por pedido — primeiro os autoritativos (subdomínio, domínio, rota), depois os de recurso (header), cada grupo pela ordem da lista | Mapeia o pedido para um TenantRef — { id } ou { domain } |
TenantSource | assim que um resolver produz uma referência | Carrega o registo do tenant (find / findByDomain). Uma referência desconhecida de um resolver autoritativo termina a resolução sem tenant; de um resolver de recurso passa ao seguinte |
ctx().tenant | no resto do pedido | O registo aberto resolvido — undefined quando nada correspondeu |
tenancy:switched | em cada entrada num tenant | Permite à cache, ao storage e ao db client reanexar a sua instância por tenant |
tenancy:created | { tenant } — emitido quando um tenant novo é criado e provisionado, portanto um listener pode assumir que o storage dele existe. Não dispara se o onProvision lançar | |
tenancy:created | uma vez, depois de um tenant novo ser criado e provisionado | Email de boas-vindas, entrada de auditoria, notificar um painel — um listener pode assumir que o storage do tenant já existe |
Fora de um pedido não há resolver, por isso entras num tenant explicitamente com tenancy.run(id, fn) — jobs, comandos da CLI e scripts de manutenção passam todos por aí, e o mesmo hook dispara.
A resolução é identificação, nunca autorização
Um tenant resolvido apenas diz a que tenant o pedido alega dizer respeito. Não verifica que quem chama lhe pertence — com o headerResolver, um utilizador autenticado do tenant A pode simplesmente enviar x-tenant-id: b. Impõe a filiação à parte com o tenantMembershipPlugin das Teams, que rejeita não-membros em toda a app com 403 TEAM_NOT_A_MEMBER.
Arranque rápido
Uma app completa que arranca e serve uma rota ciente do tenant:
import { createApp, ctx } from '@basaltkit/core'
import { fastifyPlugin, route, FASTIFY } from '@basaltkit/fastify'
import { tenancyPlugin, MemoryTenantSource, headerResolver } from '@basaltkit/tenancy'
const app = await createApp({
plugins: [
tenancyPlugin({
source: new MemoryTenantSource().add({ id: 'acme', name: 'Acme Inc', plan: 'pro' }),
resolvers: [headerResolver()], // x-tenant-id: acme
}),
fastifyPlugin({
routes: [
route({
method: 'GET',
url: '/whoami',
async handler() {
const tenant = ctx().tenant
return { tenant: tenant?.id ?? null, plan: tenant?.plan ?? 'free' }
},
}),
],
}),
],
}).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })curl http://localhost:3000/whoami -H 'x-tenant-id: acme'
# → {"tenant":"acme","plan":"pro"}
curl http://localhost:3000/whoami
# → {"tenant":null,"plan":"free"} (nenhum tenant resolvido — ver `required` abaixo)Resolvers
Um resolver mapeia um pedido recebido para uma referência de tenant. Passas uma lista, e há dois tipos de resolver:
- Autoritativos —
subdomainResolver,domainResolver,routeResolvere qualquer resolver personalizado envolvido emauthoritative(fn). Leem algo que a plataforma controla, por isso correm sempre primeiro, seja qual for a ordem da lista; vence a primeira referência que carrega um tenant existente. Se um resolver autoritativo nomeia um tenant que não existe, o pedido resolve para nenhum tenant — nunca cai para um resolver controlado pelo cliente. - De recurso —
headerResolvere resolvers personalizados não marcados. Correm, pela ordem da lista, só quando nenhum resolver autoritativo nomeou nada (o apex nu,www,localhost).
Assim, nosuch.basalt.app com x-tenant-id: globex não é globex, e um header nunca se sobrepõe a acme.basalt.app. Uma referência cujo id falha a gramática de ids de tenant (ver abaixo), ou cujo domínio não é um hostname válido, conta como não encontrada e nunca chega à source.
import {
tenancyPlugin,
MemoryTenantSource,
headerResolver,
subdomainResolver,
} from '@basaltkit/tenancy'
tenancyPlugin({
source: new MemoryTenantSource()
.add({ id: 'acme', name: 'Acme Inc' })
.add({ id: 'globex', name: 'Globex' }),
resolvers: [
subdomainResolver({ base: 'basalt.app' }), // acme.basalt.app — autoritativo
headerResolver(), // x-tenant-id: acme — só no apex nu
],
})Preferes um erro a uma regra de precedência quando os resolvers discordam? Passa onConflict: 'error': todos os resolvers correm, e dois que carregam tenants diferentes respondem 400 TENANCY_CONFLICT (ao custo de um lookup por resolver).
Os quatro resolvers incorporados
import {
subdomainResolver,
domainResolver,
headerResolver,
routeResolver,
} from '@basaltkit/tenancy'
// acme.basalt.app → { id: 'acme' }. Ignora 'www', o domínio base nu,
// subdomínios aninhados (a.b.basalt.app), e a porta.
subdomainResolver({ base: 'basalt.app' })
// app.acme.com → { domain: 'app.acme.com' }, procurado via source.findByDomain.
// Requer que a source implemente findByDomain (ver abaixo).
domainResolver()
// Lê um header HTTP (padrão 'x-tenant-id') → { id: <value> }.
headerResolver() // x-tenant-id: acme
headerResolver({ header: 'x-org' })
// Lê um parâmetro de rota (padrão 'tenant') → { id: params.tenant }.
// Corresponde a rotas como /t/:tenant/...
routeResolver() // /t/acme/...
routeResolver({ param: 'org' }) // /o/:org/...Aviso
Não confies num header fornecido pelo browser em produção. headerResolver é ideal para desenvolvimento e tráfego interno, mas um utilizador pode enviar x-tenant-id: another-customer à mão. Em produção prefere subdomainResolver / domainResolver (o DNS está sob o teu controlo), e verifica que o utilizador autenticado pertence ao tenant resolvido.
Resolvers personalizados
Um resolver é apenas uma função (request) => TenantRef | null (async permitido), onde request é a forma neutra { headers?, params?, url? } e um TenantRef é { id } ou { domain }. Escreve o teu quando os incorporados não servirem — p. ex. derivar o tenant de uma claim que o teu gateway assinou e pôs no pedido. Um resolver não marcado é de recurso; envolve-o em authoritative() só quando os clientes não conseguem forjar o que ele lê:
import { authoritative } from '@basaltkit/tenancy'
const claimResolver = authoritative((request) => {
const org = request.headers?.['x-org-claim'] // posto pelo gateway, removido dos pedidos do cliente
return typeof org === 'string' ? { id: org } : null
})
tenancyPlugin({ source, resolvers: [claimResolver, subdomainResolver({ base: 'basalt.app' })] })MemoryTenantSource é para desenvolvimento e testes. Em produção usa um TenantSource durável (abaixo) — ou implementa o contrato sobre a tua própria base de dados.
O contrato TenantSource
Um tenant é um registo aberto — { id, ...anything } — para que possas anexar quaisquer campos por tenant (name, plan, domains, definições…) e eles fazem round-trip inalterados. Um TenantSource é onde esses registos vivem; a interface completa é pequena:
import type { TenantSource } from '@basaltkit/tenancy'
const source: TenantSource = {
async find(id) { /* SELECT … WHERE id = ? */ return null }, // obrigatório
async findByDomain(domain) { return null }, // opcional — necessário para domainResolver()
async list() { return [] }, // opcional — necessário para tenancy.forEach()
}Raramente escreves isto à mão — usa MemoryTenantSource em dev, ou uma source durável em produção (ambas mostradas abaixo). Só implementa a interface tu mesmo quando os tenants já vivem numa tabela que possuis.
Domínios custom (verificados)
O domainResolver() mapeia app.acme.com → { domain } e o findByDomain carrega o tenant. Mas não podes deixar um tenant reclamar um domínio que não é dele. O CustomDomains trata disso: regista um domínio (não verificado), prova a posse com um registo DNS TXT, e só os domínios verificados resolvem.
import { CustomDomains, findByVerifiedDomain } from '@basaltkit/tenancy'
const domains = new CustomDomains({ store }) // store default: em memória
// 1. O tenant adiciona o domínio → mostras-lhe o registo DNS a publicar
const { dns } = await domains.add('acme', 'app.acme.com')
// dns → { type: 'TXT', host: '_basalt-verify.app.acme.com', value: 'basalt-domain-verify=…' }
// 2. Depois de o adicionar, verifica — um lookup DNS real confirma o token.
// verify/instructions/remove são scoped ao tenant dono.
if (await domains.verify('acme', 'app.acme.com')) { /* ativo */ }
// 3. Liga os domínios verificados à tua source com o helper — um Host forjado
// ou não verificado nunca resolve para um tenant.
const source: TenantSource = {
async find(id) { /* … */ },
findByDomain: findByVerifiedDomain(domains, (id) => /* carrega o tenant */ this.find(id)),
}Um claim não verificado não bloqueia o dono real para sempre. Passado claimTtlMs (72 h por omissão) o add() de outro tenant fica com o domínio; com challengeSecret definido o dono nem precisa de esperar — publica o registo devolvido por domains.challenge(tenantId, domain) e o add() dele vence de imediato, já verificado. Define reservedDomains com o apex da tua plataforma para que ninguém o possa reclamar, nem a qualquer subdomínio dele:
const domains = new CustomDomains({
store,
reservedDomains: ['basalt.app'], // DOMAIN_RESERVED para basalt.app e *.basalt.app
challengeSecret: env.DOMAIN_CHALLENGE_SECRET, // o mesmo valor em todas as instâncias
})Os domínios obedecem à gramática de hostname do RFC 1123: o normalizeDomain() rejeita userinfo (acme.basalt.app@evil.com), caminhos, escapes %, não-ASCII e literais IP com 400 DOMAIN_INVALID em vez de os reescrever. Regista um domínio internacionalizado na forma xn-- (domainToASCII() de node:url).
O verify() faz um lookup TXT real via node:dns (injetável nos testes). Fornece um DomainStore durável (com a forma de MemoryDomainStore) para persistir os domínios. O provisionamento do certificado TLS é infraestrutura — emite o certificado na tua plataforma (Cloudflare, Caddy, ACME) assim que o verify() devolver true.
Criar tenants
A forma de criares um tenant depende do backend.
Os ids de tenant seguem uma gramática
Um id de tenant não é um rótulo opaco: torna-se um segmento de namespace na cache (tenant:<id>:), no storage (tenants/<id>/), nos canais de realtime e nos nomes de schema. Por isso o tenancy.create() e o MemoryTenantSource.create()/save() recusam, com InvalidTenantIdError (400 TENANT_ID_INVALID), qualquer id fora de /^[a-z0-9][a-z0-9_-]{0,62}$/ ou igual ao reservado global — assim um registo self-service não consegue escolher globex:user, globex/files ou .. para se fazer passar pelas chaves ou ficheiros de outro tenant. Slugs, UUIDs e cuids cabem. O isValidTenantId(id) é exportado para o teu próprio formulário de registo; passa validateTenantId ao tenancyPlugin (e a mesma função a new MemoryTenantSource({ validateTenantId })) para a apertar ou alargar — mantém :, /, \, ., espaços e caracteres de controlo fora de qualquer substituto.
Em dev — MemoryTenantSource
Semeia-os inline; as chamadas add() encadeiam. Perdidos ao reiniciar, portanto só dev/testes:
const tenants = new MemoryTenantSource()
.add({ id: 'acme', name: 'Acme Inc' })
.add({ id: 'globex', name: 'Globex', domains: ['app.globex.com'] })
tenancyPlugin({ source: tenants, resolvers: [subdomainResolver({ base: 'basalt.app' })] })De forma durável — @basaltkit/tenancy-sqlite / -prisma
Para produção, não faças o contrato à mão — um TenantSource durável persiste os tenants através de um reinício. Ambos trazem create/save/find/findByDomain/list/remove:
import { sqliteTenantSource } from '@basaltkit/tenancy-sqlite' // nó único, zero-dep
// import { prismaTenantSource } from '@basaltkit/tenancy-prisma' // Postgres/MySQL
const tenants = sqliteTenantSource('./data/tenants.db')
// save() é um upsert — cria ou atualiza um tenant. Qualquer campo extra faz round-trip.
await tenants.save({ id: 'acme', name: 'Acme Inc', plan: 'pro', domains: ['app.acme.com'] })
// create() só insere — um id que já existe lança TenantAlreadyExistsError.
await tenants.create({ id: 'globex', name: 'Globex' })
tenancyPlugin({
source: tenants,
resolvers: [subdomainResolver({ base: 'basalt.app' }), domainResolver()],
})save e create substituem o conjunto de domínios personalizados do tenant; um domínio já possuído por outro tenant é rejeitado (o routing tem de ser inequívoco). Ver Persistência.
Dica
Registo com backend Prisma. prismaTenantSource(prisma) guarda o registo na base de dados Postgres/MySQL que já corres — ideal para múltiplas instâncias a partilhar uma lista de tenants. Adiciona os seus dois modelos com basalt prisma:sync --push, depois passa o teu PrismaClient gerado. A mesma superfície create/save/find/findByDomain/list/remove.
No sign-up — provisionar um tenant sob demanda
Um SaaS real cria tenants quando um cliente se regista, e quem carrega em Criar num painel normalmente não tem nem o conhecimento nem o acesso para correr uma migração a seguir. Declara o onProvision uma vez, e todos os caminhos de criação — uma rota de admin, o basalt tenant:create, um script de seed — passam a trazer o storage do tenant à existência antes de alguém lhe poder encaminhar um pedido.
// src/app.ts
import { provisionTenantSchema, tenantSchema, migrateTenants } from '@basaltkit/prisma'
tenancyPlugin({
source,
resolvers: [subdomainResolver({ base: 'example.com' })],
async onProvision(tenant) {
const admin = new PrismaClient()
await provisionTenantSchema(admin, tenantSchema(tenant.id)) // CREATE SCHEMA IF NOT EXISTS
await migrateTenants({
tenants: [tenant.id],
target: { mode: 'schema', url: process.env.DATABASE_URL!, provision: admin },
})
},
})Depois cria através do serviço, nunca através da source:
// src/modules/tenants/tenants.routes.ts
route({
method: 'POST',
url: '/tenants',
meta: { auth: true }, // protegida por admin
body: z.object({ id: z.string().min(1), name: z.string() }),
async handler({ body }) {
// persiste → provisiona → emite tenancy:created, por esta ordem
return ctx().container.get(TENANCY).create(body)
},
})basalt tenant:create acme --name=Acme
# → Created and provisioned tenant "acme".O source.create() salta tudo isto
A source só escreve a linha. Um tenant cujo registo existe mas cujo schema não existe é encaminhável imediatamente — o subdomainResolver manda-lhe tráfego no momento em que é gravado — e o primeiro pedido morre num erro cru da base de dados. Passar pelo tenancy.create() é o que fecha essa janela.
O create() nunca sobrescreve um tenant existente
Um id que já existe — seja qual for o estado — é recusado com TenantAlreadyExistsError (409 TENANT_ALREADY_EXISTS) antes de se escrever seja o que for: nenhum hook dispara e o onProvision não corre, por isso um sign-up submetido duas vezes não consegue substituir o owner de um tenant nem reprovisionar storage com dados. Um tenant que ficou failed (ou ainda provisioning) termina-se com tenancy.provision(id), não criando-o outra vez; uma atualização intencional do registo é source.save(). As sources duráveis recusam o duplicado no próprio insert, por isso de dois creates concorrentes do mesmo id ganha exatamente um.
O onProvision corre dentro do contexto do novo tenant, portanto o ctx().tenant e qualquer cliente com scope de tenant resolvem corretamente — o mesmo contrato do onMigrate e do onSeed. Semear dados iniciais também pertence aqui:
async onProvision(tenant) {
await provisionTenantSchema(admin, tenantSchema(tenant.id))
await migrateTenants({ tenants: [tenant.id], target })
await db<PrismaClient>().setting.create({ data: { key: 'onboarded', value: 'true' } })
}Reage ao tenant já pronto com o hook — ele só dispara depois de o provisionamento ter tido sucesso, por isso podes tocar nos dados do novo tenant com segurança:
app.hooks.on('tenancy:created', async ({ tenant }) => {
await mailer.send({ to: owner(tenant), subject: `${tenant.name} está pronto` })
})Faz o onProvision idempotente
Se ele lançar, o erro chega a quem chamou e o tenancy:created não dispara — mas o registo do tenant já foi escrito, porque a source persiste primeiro. Esse meio-estado não é revertido de propósito: nem toda a TenantSource sabe apagar, e um delete falhado por cima de um provisionamento falhado destrói a evidência. Escreve-o de forma a que uma nova tentativa — tenancy.provision(id) — consiga terminar o trabalho: CREATE SCHEMA IF NOT EXISTS, migrate deploy.
Também corre inline: um handler HTTP que chame create() espera pela migração toda. Isso é aceitável para um schema e um punhado de migrações, e errado para qualquer coisa lenta — passa a parte lenta para um job em fila e deixa a rota responder.
O endereço com que um tenant é criado
Todo TenantSource durável lê os endereços de um tenant a partir de uma única chave — tenant.domains — e uma aplicação que nunca a passa cria tenants sem nenhum. E em silêncio: o subdomainResolver corta o sufixo do Host e responde sem nunca consultar a tabela, por isso o tenant serve tráfego e o que falta é apenas o registo de que o endereço lhe pertence.
Nada parte até algo precisar desse registo. O domainResolver() não encontra o tenant, não se consegue anexar-lhe um domínio custom, e nada impede um segundo tenant de reclamar o mesmo endereço — a unicidade vive na tabela que ficou vazia. É o pior tipo de falha: não rebenta, omite. Uma instalação pode correr um ano com o conjunto de domínios vazio e descobri-lo quando o primeiro cliente pede o domínio próprio, com todas as linhas históricas por preencher.
Declara-o uma vez, no plugin:
tenancyPlugin({
source,
resolvers: [subdomainResolver({ base: 'example.com' })],
canonicalDomain: (tenant) => `${tenant.id}.${process.env.APP_DOMAIN}`,
})O tenancy.create() aplica-o antes de o registo ser persistido, por isso todos os caminhos de criação o recebem — sign-up público, uma rota de admin, o basalt tenant:create, um script de seed — em vez de cada um ter de se lembrar. Devolve undefined para recusar, no caso de um tenant que não deva ter endereço próprio.
É acrescentado, nunca substituído
O domínio canónico junta-se ao que o tenant já declara. As sources substituem o conjunto inteiro de domínios ao gravar, por isso substituir apagaria o app.acme.com do próprio cliente na próxima vez que alguma coisa chamasse create().
Quando o provisionamento é mais longo que o pedido
O onProvision corre inline por omissão: o create() espera por ele, e quem chama sabe que o tenant está utilizável quando a chamada retorna. É o certo para um schema e um punhado de migrações, e errado assim que o trabalho passa a durar mais do que um pedido HTTP.
Passa a provision: 'deferred' e o create() retorna assim que o registo é escrito, marcado como provisioning:
tenancyPlugin({ source, resolvers, onProvision, provision: 'deferred' })const tenant = await tenancy.create({ id: 'acme' })
tenant.status // 'provisioning'
// …e os pedidos para ele recebem 503Nada é agendado por ti, e isso é de propósito. O trabalho em segundo plano corre noutro processo, onde um closure deste não chega — por isso o worker volta a entrar com o id:
// jobs/provision-tenant.ts
export const ProvisionTenant = defineJob({
name: 'tenant.provision',
handle: ({ id }: { id: string }) => ctx().container.get(TENANCY).provision(id),
})
// onde quer que cries o tenant
await tenancy.create({ id }) // retorna já, em 'provisioning'
await ctx().container.get(QUEUE).dispatch(ProvisionTenant, { id })O provision(id) corre o onProvision, muda o estado para ready e emite o tenancy:created — a mesma meta que o caminho inline atravessa. Mantém-no idempotente: depois de uma falha o estado fica failed, e uma nova tentativa tem de conseguir terminar o trabalho.
Este desenho mantém o @basaltkit/queue completamente fora do @basaltkit/tenancy. O dispatch é da app, portanto qualquer agendador serve — uma fila, um cron, um basalt tenant:run à mão.
O estado, e porquê 503
| Estado | Serve pedidos | Como lá chega |
|---|---|---|
(nenhum, ou null) | ✅ | Todos os tenants criados antes de existir provisionamento. Tratados como prontos — qualquer outra coisa poria uma frota em produção offline na atualização |
ready | ✅ | O onProvision teve sucesso |
provisioning | ❌ 503 | O create() escreveu o registo; o trabalho ainda não acabou |
failed | ❌ 503 | O onProvision lançou. O registo é mantido, não apagado — é a evidência de que o tenant foi tentado |
deleting | ❌ 503 | O destroy() começou. Marcado antes de o storage ser tocado, para nenhum pedido chegar a um schema a ser apagado por baixo dele |
suspended | ❌ 403 TENANT_SUSPENDED | Foi a tua app que o escreveu — faturação em atraso, abuso. O tenancy nunca o define |
qualquer outro (active, disabled, …) | ❌ 500 TENANT_STATUS_UNKNOWN | Um valor que o tenancy não reconhece. Recusado em vez de adivinhado |
503, e não 404. O tenant existe; apenas ainda não serve, e o 503 é o estado que um cliente pode voltar a tentar. Um 404 diria o contrário.
Uma suspensão é 403, e não 503. O storage está bem e voltar a tentar não ajuda — a conta fica bloqueada até a app levantar a suspensão. Bloqueia um tenant com source.save({ ...tenant, status: 'suspended' }); repõe-no com 'ready'.
Um estado desconhecido falha fechado. Se os teus registos dizem active para um tenant a servir, o tenancy não pode saber que isso significa "o storage está utilizável", por isso o pedido é recusado com uma mensagem que nomeia o valor visto. Guarda ready (ou nenhum estado). O assertTenantServing(tenant) faz a mesma verificação fora do HTTP; o isTenantReady(tenant) é a sua forma booleana.
Ler o tenant
O tenant resolvido vive no contexto do pedido — sem passagem de argumentos. É o registo aberto que guardaste, portanto qualquer campo personalizado está mesmo ali:
import { ctx } from '@basaltkit/core'
export async function currentTenant() {
const tenant = ctx().tenant // undefined fora de um contexto de tenant
return {
id: tenant?.id ?? null,
name: tenant?.name ?? null, // qualquer campo que guardaste faz round-trip
plan: tenant?.plan ?? 'free',
}
}Define required: true no plugin para rejeitar pedidos não resolvidos à partida com um 404 TENANCY_NOT_RESOLVED — pedidos mal encaminhados falham ruidosamente em vez de correrem contra dados globais.
O true aplica-se a todas as rotas, o que a maioria das apps não aguenta: um health check não tem tenant nenhum para enviar, e uma landing page ou um endpoint público de preços também não. Isenta-os por caminho, em vez de abdicares da proteção em todo o lado:
tenancyPlugin({
source: tenants,
resolvers: [headerResolver()],
required: { except: ['/', '/health', '/openapi.json', /^\/public\//] },
})As entradas são strings exatas ou expressões regulares, comparadas com o caminho sem a query string — por isso /health cobre também /health?probe=1. Um URL que não se consiga comparar é tratado como obrigatório, portanto a proteção falha fechada.
Separar rotas centrais de rotas de tenant
Uma lista de caminhos funciona, mas põe a decisão num ficheiro diferente da rota que descreve: muda-se o URL e a exceção deixa de coincidir em silêncio. Declara antes na própria rota, com meta.tenant:
// Rota central: nunca tem tenant.
route({ method: 'GET', url: '/pricing', meta: { tenant: false }, handler })
// Rota de tenant: recusa o pedido se nenhum for resolvido.
route({ method: 'GET', url: '/invoices', meta: { tenant: true }, handler })O meta.tenant sobrepõe-se ao required da app nos dois sentidos, por isso a combinação que a maioria das apps quer é negar por omissão, abrir rota a rota:
tenancyPlugin({ source: tenants, resolvers: [headerResolver()], required: true })Todas as rotas passam a precisar de tenant, e as poucas centrais — health check, landing page, registo, criação de tenants — dizem-no ao lado do handler, onde quem revê o código as vê. O required: { except } continua disponível e a funcionar; usa-o para caminhos que não são teus, como rotas montadas por outro pacote.
Uma rota com meta: { tenant: false } continua a resolver o tenant quando ele existe, portanto o ctx().tenant está preenchido em acme.example.com/pricing. O que se levanta é só a exigência.
Isentar um caminho levanta apenas a exigência de tenant. A autenticação, as verificações de subscrição e todas as outras proteções continuam a correr.
Manter required: false continua válido quando as rotas centrais são mais do que as de tenant, mas nesse caso cada handler fica responsável por tratar o tenant ausente.
Também podes ler o tenant através da fachada TENANCY — útil em serviços que de outra forma não tocam em ctx():
import { TENANCY } from '@basaltkit/tenancy'
const tenancy = app.container.get(TENANCY)
tenancy.current() // Tenant | undefined — o tenant do contexto ativo
await tenancy.find('acme') // Tenant | null — procura um por id, ignorando o contextoIsolamento automático
Não isolas nada à mão. O mesmo código comporta-se por tenant:
await cache.put('config', value) // chave prefixada com tenant:<id>
await storage.disk('uploads').put(path, f) // guardado sob tenants/<id>/
await SendEmail.dispatch({ userId }) // tenant restaurado no worker
logger.info('done') // o log transporta tenantIdScoping fail-closed — a família tenantScoped()
As linhas da base de dados são o único sítio onde o isolamento é trabalho TEU: um repositório que esqueça o filtro tenantId devolve as linhas de todos os tenants — e com Prisma, where: { tenantId: ctx().tenant?.id } descarta silenciosamente o filtro quando o tenant é undefined, transformando um bug numa fuga de dados entre tenants que devolve 200 OK. Os três helpers exportados por @basaltkit/tenancy nunca fazem isso: quando não há nada a que dar âmbito, lançam em vez de devolverem undefined.
| Helper | Assinatura | Devolve | Lança quando |
|---|---|---|---|
requireTenant() | () => Tenant | O registo completo do tenant do contexto ativo | Não há tenant no contexto |
requireTenantId(fallback?) | (fallback?: string) => string | O id do tenant do contexto; senão o fallback | Não há tenant no contexto nem fallback |
tenantScoped(where?) | <W>(where?: W) => W & { tenantId: string } | A tua cláusula where com o tenantId do tenant do contexto fundido em último | Não há tenant no contexto — um tenantId no where nunca é usado como fallback |
Os três lançam TenantRequiredError (400 TENANT_REQUIRED).
import { requireTenant, requireTenantId, tenantScoped, TenantRequiredError } from '@basaltkit/tenancy'
// Uma query que nunca pode correr sem âmbito:
const rows = await db.project.findMany({ where: tenantScoped({ archived: false }) })
// → { archived: false, tenantId: 'acme' }
// O registo completo, quando precisas de mais do que o id:
const plan = requireTenant().plan
// Código de sistema (um job, um comando da CLI) pode fixar um tenant deliberadamente:
const tenantId = requireTenantId(job.tenantId)Vale a pena enunciar exatamente três garantias, porque são o que torna a família segura de usar sobre dados derivados de input:
- O tenant do contexto ganha sempre. O
tenantScoped()espalha otenantIdem último, pelo que umtenantIdinfiltrado nowherepor input do cliente não consegue alargar nem trocar o âmbito:tenantScoped({ tenantId: 'globex' })dentro do contexto da Acme continua a dar{ tenantId: 'acme' }. - O
tenantScoped()tira o tenant apenas do contexto. UmtenantIddentro dowherenunca é fallback: owhereé muitas vezes construído a partir de input do cliente e, sem tenant resolvido (requiredéfalsepor omissão), isso deixaria um pedido escolher qualquer tenant omitindo o header de tenant. Por issotenantScoped({ tenantId: 'globex' })sem tenant no contexto lança. - Um id explícito só é honrado pelo
requireTenantId(fallback), e só quando não há tenant no contexto. Esse é o caminho do código de sistema — um worker de fila ou um comandobasalta fixar um tenant (ou embrulha o trabalho emtenancy.run(id, …)). Dentro de um pedido nunca consegue sobrepor-se ao tenant resolvido. - Sem nenhum dos dois, lança. O valor é sempre um id de tenant real, nunca um filtro que desaparece em silêncio. É esse o objetivo: um
400é melhor do que uma leitura entre tenants.
A mesma forma noutros sítios
O @basaltkit/activity expõe a mesma ideia como opção de query: new Activity({ tenantScoped: 'required' }) faz as queries do seu trilho lançarem em vez de devolverem silenciosamente as linhas de todos os tenants. Vários pacotes trazem a sua própria variante fail-closed da verificação — SEARCH_TENANT_REQUIRED, FILE_TENANT_REQUIRED, COMMENT_TENANT_REQUIRED, AUDIT_TENANT_REQUIRED — todas com o mesmo significado: passa um tenantId ou corre dentro de um contexto de tenant.
Estas verificações são condicionais à tenancy estar registada. O tenancyPlugin define um marcador tenancy:active na metadata do container, e cada package genérico lê-o para decidir se falha fechado: com tenancy ligada, SEARCH_TENANT_REQUIRED / FILE_TENANT_REQUIRED / COMMENT_TENANT_REQUIRED / AUDIT_TENANT_REQUIRED / MissingCacheScopeError aplicam-se; sem tenancy, não existe dimensão de tenant e as mesmas chamadas funcionam sem âmbito. É a regra beyond-SaaS — um package genérico nunca exige tenancy. O @basaltkit/cache foi o primeiro a usar o marcador, trocando a predefinição do seu onMissingScope de 'global' para 'error' em apps multi-tenant; vê Caching.
Correr código num tenant
Fora de um pedido — num job, num script, ou em manutenção — não há resolver, por isso entras num tenant explicitamente. run() define ctx().tenant, emite tenancy:switched (que reanexa a cache, o storage, o db client… do tenant), e restaura o contexto circundante depois:
import { TENANCY } from '@basaltkit/tenancy'
import { db } from '@basaltkit/prisma'
const tenancy = app.container.get(TENANCY)
// Passa um id (carregado da source; lança TenantNotFoundError se desconhecido)
// ou um objeto Tenant que já tenhas.
const total = await tenancy.run('acme', async () => {
return db<PrismaClient>().invoice.count() // com âmbito na Acme
})
// Manutenção em massa: visita cada tenant, cada um no seu próprio contexto, com
// concorrência limitada (padrão 5). Requer source.list().
await tenancy.forEach(async (tenant) => {
await tenancy.run(tenant, async () => {
// …trabalho por tenant, totalmente isolado…
})
}, { concurrency: 5 })Reage a mudanças de contexto em qualquer lugar com o hook:
app.hooks.on('tenancy:switched', ({ tenant }) => {
logger.info(`working for tenant ${tenant.id}`)
})Comandos da CLI
O tenancyPlugin regista seis comandos no bucket da CLI, por isso aparecem assim que o @basaltkit/cli está presente — sem ligação extra:
| Comando | Precisa de | O que faz |
|---|---|---|
basalt tenant:list | source.list() | Tabula todos os tenants (apenas campos escalares) |
basalt tenant:create <id> [--name=… --anyField=…] | source.create() ou save() | Persiste um novo tenant; cada flag torna-se um campo. Um id existente é recusado (código de saída 1) |
basalt tenant:destroy <id> [--force] [--yes] | source.delete() | Marca o tenant como deleting, corre o onDeprovision no contexto dele e remove o registo. Pergunta primeiro; --yes salta a pergunta, --force remove o registo mesmo que a limpeza falhe |
basalt tenant:migrate [--tenant=<id>] | onMigrate | Corre o teu hook de migração por tenant dentro do contexto de cada um |
basalt tenant:seed [--tenant=<id>] | onSeed | Corre o teu hook de seed por tenant dentro do contexto de cada um |
onProvision | (tenant) => void | Promise<void> | — |
onDeprovision | (tenant) => void | Promise<void> | — |
provision | 'inline' | 'deferred' | 'inline' |
basalt tenant:run <id> <command> [args…] | — | Corre qualquer outro comando registado dentro do contexto de um tenant |
O onMigrate / onSeed são onde vai o trabalho específico da base de dados — a framework limita-se a iterar os tenants e a entrar em cada contexto:
tenancyPlugin({
source: tenants,
resolvers: [subdomainResolver({ base: 'basalt.app' })],
onMigrate: async (tenant) => { await migrateSchemaFor(tenant.id) },
onSeed: async (tenant) => { await db<PrismaClient>().plan.create({ data: { name: 'free' } }) },
})Um hook em falta é reportado (No migrate hook configured. …) com código de saída 1 em vez de não fazer nada em silêncio; um TenantSource que não implementa list() / create() é reportado da mesma forma.
Modos de isolamento
@basaltkit/prisma implementa três estratégias de isolamento. O teu código de query mantém-se db<PrismaClient>().user.findMany() nas três — o modo é configuração do prismaPlugin, não uma reescrita. Escolhe um:
| Modo | Como | Isolamento | Quando |
|---|---|---|---|
| Base de dados partilhada | um client, tenancyExtension() adiciona um filtro tenantId | lógico | maioria das apps; o mais barato de correr |
| Schema por tenant | uma base de dados, um schema PostgreSQL por tenant | forte | isolamento sem N bases de dados |
| Base de dados por tenant | uma base de dados separada (+ client) por tenant | o mais forte | conformidade, backups por tenant |
Base de dados partilhada (padrão) — um client com uma coluna tenantId em cada modelo. A extensão força o filtro do tenant atual em cada leitura e update, carimba-o em cada create (incluindo creates aninhados), restringe connect/update/delete aninhados ao tenant, recusa mover uma linha para outro tenant e recusa operações brutas ou desconhecidas dentro de um contexto de tenant — o código não pode esquecer nem sobrepor:
import { PrismaClient } from '@prisma/client'
import { prismaPlugin, tenancyExtension } from '@basaltkit/prisma'
const db = new PrismaClient().$extends(
tenancyExtension({
tenantField: 'tenantId', // nome da coluna (padrão 'tenantId')
// onMissingTenant é 'error' por omissão: sem tenant no contexto → lança
// PRISMA_TENANT_MISSING em vez de correr sobre todos os tenants.
}),
)
prismaPlugin({ client: db })
// Leituras entre tenants deliberadas (back-office, jobs) têm o SEU PRÓPRIO
// client — nunca ponhas 'bypass' no client principal da app.
export const adminDb = new PrismaClient().$extends(
tenancyExtension({ onMissingTenant: 'bypass' }),
)A extensão trabalha sobre os argumentos da query, por isso não sabe que colunas escalares são chaves estrangeiras (data: { projectId } não é verificado). Usa chaves estrangeiras compostas (tenantId, id) e RLS como garantia ao nível da base de dados. O tenancyExtension({ rls: true }) define o tenant para o RLS do Postgres em cada operação — ver a parte de RLS do guia de Segurança.
Schema por tenant — uma base de dados, um schema PostgreSQL por tenant. Cada tenant recebe um client cujo URL de ligação transporta ?schema=tenant_<id>, para que o Prisma defina o search_path no momento da ligação (fiável, ao contrário da troca de search_path por pedido num pool partilhado). Os clients são mantidos num pool limitado (só clients inactivos são despejados; ver o pool de clientes por tenant):
import { PrismaClient } from '@prisma/client'
import { prismaPlugin, provisionTenantSchema, tenantSchema } from '@basaltkit/prisma'
prismaPlugin({
schemaPerTenant: {
url: env.DATABASE_URL,
createClient: (url) => new PrismaClient({ datasourceUrl: url }),
prefix: 'tenant_', // schema = tenant_<id> (padrão)
},
destroy: (client) => client.$disconnect(), // fecha um client despejado do pool
max: 25, // clients mais-recentemente-usados mantidos abertos (padrão 10)
})
// Provisiona o schema de um novo tenant (uma ligação admin com $executeRawUnsafe):
const admin = new PrismaClient()
await provisionTenantSchema(admin, tenantSchema('acme')) // CREATE SCHEMA IF NOT EXISTS "tenant_acme"tenantSchema() é injetiva: ids canónicos (acme, acme_co) são usados tal como estão, e qualquer outro id (ACME, acme-co, UUIDs) recebe um sufixo __<hash> (tenant_acme_co__<16 hex>), para um tenant novo nunca aterrar no schema de um tenant existente. Os clients despejados do pool são fechados com $disconnect() por omissão, e primeiros pedidos concorrentes para um tenant partilham um único client.
Base de dados por tenant — uma base de dados (e client) separada por tenant, via o mesmo pool LRU. Dá-lhe uma factory chaveada por id de tenant:
prismaPlugin({
forTenant: (id) => new PrismaClient({ datasourceUrl: urlFor(id) }),
destroy: (client) => client.$disconnect(),
max: 20,
})Em todos os modos o plugin anexa o client certo ao contexto em cada pedido HTTP e dentro de tenancy.run() — lê-lo com db<PrismaClient>(), que lança DB_UNAVAILABLE fora de um contexto de tenant. Ver Database-per-tenant para a receita completa com pool.
Migrações por tenant
Schema- e database-per-tenant precisam de migrações corridas para cada tenant. migrateTenants orquestra isso — concorrência limitada, provisionando o schema primeiro (modo schema), e um relatório por tenant onde uma falha nunca aborta os restantes. Liga-o como um comando basalt tenant:migrate:
import { tenantMigrateCommand, provisionTenantSchema } from '@basaltkit/prisma'
import { commandsPlugin } from '@basaltkit/cli'
commandsPlugin([
tenantMigrateCommand({
tenants: () => tenants.list().then((all) => all.map((t) => t.id)),
target: {
mode: 'schema',
url: env.DATABASE_URL,
provision: db, // um client com $executeRawUnsafe — CREATE SCHEMA IF NOT EXISTS
},
}),
])basalt tenant:migrate
# ok acme (tenant_acme)
# FAIL globex (tenant_globex) — <error>
# Done: 1 migrated, 1 failed.O migrator padrão delega para prisma migrate deploy com o URL de ligação com âmbito de cada tenant; passa migrate para o sobrepor.
Referência de opções
tenancyPlugin(options):
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
source | TenantSource | — (obrigatório) | De onde são carregados os registos dos tenants — MemoryTenantSource em dev, tenancy-sqlite/tenancy-prisma (ou a tua própria tabela) em produção |
resolvers | TenantResolver[] | — (obrigatório) | Primeiro os autoritativos (subdomínio, domínio, rota, authoritative(fn)) — um tenant desconhecido que nomeiem resolve para nenhum; os de recurso (header) só quando nenhum autoritativo nomeou nada. Em cada grupo vence a primeira referência que carrega um tenant |
onConflict | 'precedence' | 'error' | 'precedence' | 'error' corre todos os resolvers e responde 400 TENANCY_CONFLICT quando dois carregam tenants diferentes |
required | boolean | { except: (string | RegExp)[] } | false | Rejeita um pedido que não resolveu nenhum tenant com 404 TENANCY_NOT_RESOLVED, em vez de o correr sem tenant. { except } isenta caminhos (health checks, landing pages) e continua a proteger o resto |
onMigrate | (tenant) => void | Promise<void> | — | Trabalho por tenant para o basalt tenant:migrate, corrido dentro do contexto de cada tenant |
onSeed | (tenant) => void | Promise<void> | — | Trabalho por tenant para o basalt tenant:seed, corrido dentro do contexto de cada tenant |
onProvision | (tenant) => void | Promise<void> | — | Traz o storage de um tenant NOVO à existência, dentro do contexto dele, a partir do tenancy.create() e do basalt tenant:create. Sem isto um tenant é roteável antes de o schema existir |
onDeprovision | (tenant) => void | Promise<void> | — | Desfaz esse storage, dentro do contexto do tenant, a partir do tenancy.destroy(). Sem isto o registo sai e o schema fica |
provision | 'inline' | 'deferred' | 'inline' | 'inline' — o create() espera, por isso o tenant está utilizável quando ele retorna. 'deferred' — o create() retorna já com estado provisioning e o resolver responde 503 até correr tenancy.provision(id) |
canonicalDomain | (tenant) => string | undefined | — | O endereço em que um tenant novo é alcançável, acrescentado a tenant.domains pelo tenancy.create() antes de o registo ser persistido. Sem isto a tabela de domínios fica vazia e ninguém é dono do endereço |
validateTenantId | (id: string) => boolean | isValidTenantId | A gramática de ids que o tenancy.create() e o tenancy.run() impõem (/^[a-z0-9][a-z0-9_-]{0,62}$/, menos global); um id rejeitado lança InvalidTenantIdError antes de algo ser escrito, e uma referência de resolver com um desses ids resolve para nenhum tenant |
As fábricas de resolvers incorporadas:
| Fábrica | Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|---|
subdomainResolver({ base }) | base | string | — (obrigatório) | O domínio de topo sob o qual vivem os teus tenants. acme.basalt.app → { id: 'acme' }; www, o domínio base nu e subdomínios aninhados (a.b.basalt.app) são ignorados. Autoritativo |
domainResolver() | — | — | — | O Host inteiro → { domain }, resolvido através de source.findByDomain. Para domínios do cliente; exige esse método. Autoritativo |
headerResolver({ header }) | header | string | 'x-tenant-id' | Lê um header do pedido → { id: <value> }. Muda-o quando o teu gateway já injeta outro header. De recurso (controlado pelo cliente) |
routeResolver({ param }) | param | string | 'tenant' | Lê um parâmetro de rota → { id: params.tenant }. Para tenancy por caminho (/t/:tenant/…). Autoritativo |
Cada fábrica devolve um TenantResolver simples — (request) => TenantRef | null, com uma flag authoritative opcional — por isso um resolver personalizado encaixa no mesmo array. O valor de Host é canonicalizado (minúsculas, porta e pontos finais removidos) antes da correspondência, pelo que Victim.com:443 e victim.com. dão a mesma chave; um Host fora da gramática de hostname (userinfo, caminho, %, não-ASCII, literal IP) não corresponde a nada.
new CustomDomains(options):
| Opção | Tipo | Predefinição | Propósito |
|---|---|---|---|
store | DomainStore | new MemoryDomainStore() | Onde vivem os domínios registados. Uma implementação durável tem de suportar o add() com uma restrição UNIQUE — esse insert é a barreira anti-roubo |
now | () => number | Date.now | Relógio injetável (testes) |
token | () => string | 24 bytes aleatórios, base64url | Gerador do token de verificação (testes) |
resolveTxt | (host) => Promise<string[][]> | resolveTxt de node:dns/promises | Consulta DNS usada pelo verify(); substitui-a nos testes |
claimTtlMs | number | 72 h | Quanto tempo um claim não verificado segura um domínio antes de o add() de outro tenant o poder tomar. Domínios verificados nunca expiram (um obsoleto só cede a um registo de challenge() — vê reverify()) |
reservedDomains | string[] | [] | Os domínios da própria plataforma; cada um e todos os seus subdomínios são recusados com DOMAIN_RESERVED |
challengeSecret | string | — | Ativa challenge(tenantId, domain): publicar esse registo TXT deixa o add() do dono tomar de imediato um claim não verificado ocupado. O mesmo valor em todas as instâncias |
O domains.verify(tenantId, domain, { force }) faz curto-circuito num domínio já verificado a não ser que force esteja definido. Corre-o com force: true de forma agendada: um domínio cujo DNS foi mais tarde removido ou reapontado é des-verificado numa re-verificação falhada e deixa de resolver — a defesa contra a tomada de domínios pendentes.
Para um job agendado, prefere os helpers de sistema, que não precisam do id do tenant:
schedule.call('reverify-domains', async () => {
const { revoked, errors } = await domains.reverifyAll()
if (revoked.length) log.warn({ revoked }, 'custom domains un-verified')
}).hourly()O domains.reverify(domain) re-verifica o tenant que detém o domínio, seja ele qual for, e devolve { domain, tenantId, status }: valid, revoked (o registo desapareceu de forma definitiva — NXDOMAIN, sem TXT, ou sem o valor esperado — por isso o claim é des-verificado), dns-error (um timeout ou SERVFAIL: fica verificado, para que uma falha de DNS nunca des-verifique todos os domínios de uma vez), unverified, ou changed (o registo mudou de mãos entretanto; fica como está). O reverifyAll() corre-o sobre DomainStore.listVerified() — ou sobre os { domains } que passares, para uma store sem esse método — e devolve { checked, revoked, errors, results }.
Um claim verificado obsoleto também cede diretamente ao novo dono: quando um domínio expira e outra pessoa o compra, o novo dono publica o registo de challenge(tenantId, domain) (com challengeSecret definido) e chama add(). Se essa mesma consulta já não mostrar o registo do titular, o domínio passa para o novo dono, verificado; enquanto o registo do titular continuar publicado — ou a consulta falhar — o claim mantém-se e o add() lança DOMAIN_TAKEN.
Modos de falha & resolução de problemas
| Erro | Código | HTTP | Quando |
|---|---|---|---|
TenantRequiredError | TENANT_REQUIRED | 400 | tenantScoped() / requireTenantId() / requireTenant() correram sem tenant no contexto (e, no requireTenantId, sem fallback explícito) |
InvalidTenantIdError | TENANT_ID_INVALID | 400 | tenancy.create(), tenancy.run(), provision(id), destroy(id) (ou MemoryTenantSource.create()/save()) com um id fora da gramática de ids de tenant ou um id reservado. Nada é escrito |
TenantResolutionConflictError | TENANCY_CONFLICT | 400 | onConflict: 'error' e dois resolvers carregaram tenants diferentes (p. ex. um x-tenant-id que contradiz o Host) |
TenancyNotResolvedError | TENANCY_NOT_RESOLVED | 404 | required: true e nenhum resolver produziu uma referência que carregasse um tenant |
TenantNotFoundError | TENANT_NOT_FOUND | 500 | tenancy.run('unknown-id', …), ou forEach() sobre um TenantSource sem list() |
TenantNotReadyError | TENANT_NOT_READY | 503 | Um pedido resolveu para um tenant com estado provisioning, failed ou deleting. 503, e não 404: o tenant existe e o cliente pode voltar a tentar |
TenantSuspendedError | TENANT_SUSPENDED | 403 | Um pedido resolveu para um tenant com estado suspended. Voltar a tentar não ajuda |
TenantStatusUnknownError | TENANT_STATUS_UNKNOWN | 500 | Um pedido resolveu para um tenant com um estado que o tenancy não reconhece (active, um erro de escrita). Guarda ready ou nenhum estado para um tenant a servir |
TenantCreateUnsupportedError | TENANT_CREATE_UNSUPPORTED | 500 | tenancy.create() numa source que não implementa nem create() nem save() — por exemplo uma baseada num ficheiro de configuração estático |
TenantAlreadyExistsError | TENANT_ALREADY_EXISTS | 409 | tenancy.create() (ou o create() de uma source) para um id que já existe. Nada é escrito. Um tenant failed/provisioning retoma-se com tenancy.provision(id); uma atualização intencional é source.save() |
DomainTakenError | DOMAIN_TAKEN | 409 | domains.add() para um domínio que outro tenant registou — verificado (e com o registo TXT ainda publicado, ou sem um registo de challenge() teu), ou não verificado e mais recente que claimTtlMs |
DomainNotFoundError | DOMAIN_NOT_FOUND | 404 | verify / instructions / remove para um domínio que não está registado |
DomainForbiddenError | DOMAIN_FORBIDDEN | 403 | Um tenant agiu sobre um domínio pertencente a um tenant diferente |
DomainReservedError | DOMAIN_RESERVED | 403 | domains.add() para um domínio em reservedDomains ou um subdomínio dele |
InvalidDomainError | DOMAIN_INVALID | 400 | Um domínio que não é um hostname (userinfo, caminho, %, não-ASCII, literal IP) |
MissingCacheScopeError | CACHE_SCOPE_MISSING | 500 | Uma leitura/escrita de cache correu sem tenant com a tenancy ativa — vê Caching |
NotATeamMemberError | TEAM_NOT_A_MEMBER | 403 | O tenantMembershipPlugin não encontrou filiação do utilizador no tenant resolvido — vê Teams |
TENANT_REQUIREDnum job em segundo plano ou num script — não há resolver fora de um pedido. Envolve o trabalho emtenancy.run(tenantId, …), ou passa o id explicitamente:requireTenantId(job.tenantId).TENANT_REQUIREDnuma rota legitimamente central (sign-up, landing page, administração da plataforma) — essas rotas não deviam sequer chamartenantScoped(). Consulta a tabela central deliberadamente, e declarameta: { tenant: false }na rota em vez de aliviar orequiredpara a app toda — vê o padrão multi-tenant.TENANCY_NOT_RESOLVEDembora o header/subdomínio pareça correto — a referência resolveu mas o registo não carregou. Um subdomínio ou domínio desconhecido termina a resolução — o header não é consultado nesse caso — por isso é quase sempre um tenant em falta na source (ou, com odomainResolver, um domínio que nunca foi verificado), ou um header enviado para o subdomínio de outro tenant. Confirma combasalt tenant:list.403 TEAM_NOT_A_MEMBERlogo após trocar de tenant — é o esperado, e é o objetivo: o tenant resolveu, a verificação de filiação recusou-o a seguir. A resolução de tenant é identificação, nunca autorização — vê Teams.- Um domínio custom deixou de resolver sozinho — uma re-verificação agendada
reverify()/reverifyAll()(ouverify(…, { force: true })) falhou e des-verificou-o. Volta a publicar o registo TXT_basalt-verify.<domain>.
Eventos
| Hook | Payload |
|---|---|
tenancy:switched | { tenant } — emitido em cada entrada num contexto de tenant, pelo enricher HTTP e pelo tenancy.run() |
tenancy:destroyed | { tenant } — emitido pelo tenancy.destroy() depois de o onDeprovision correr e o registo ser apagado da source |
Os registos duráveis de tenants e as opções de base de dados por tenant estão em Persistência; o fluxo de sign-up ponta a ponta está no cookbook de SaaS multi-tenant e em Criar um tenant.