Skip to content

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çaCorreResponsabilidade
TenantResolverpor pedido — primeiro os autoritativos (subdomínio, domínio, rota), depois os de recurso (header), cada grupo pela ordem da listaMapeia o pedido para um TenantRef — { id } ou { domain }
TenantSourceassim que um resolver produz uma referênciaCarrega 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().tenantno resto do pedidoO registo aberto resolvido — undefined quando nada correspondeu
tenancy:switchedem cada entrada num tenantPermite à 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:createduma vez, depois de um tenant novo ser criado e provisionadoEmail 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:

ts
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 })
bash
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, routeResolver e qualquer resolver personalizado envolvido em authoritative(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 — headerResolver e 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.

ts
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 ​

ts
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ê:

ts
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:

ts
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.

ts
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:

ts
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:

ts
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:

ts
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.

ts
// 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:

ts
// 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)
  },
})
bash
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:

ts
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:

ts
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:

ts
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:

ts
tenancyPlugin({ source, resolvers, onProvision, provision: 'deferred' })
ts
const tenant = await tenancy.create({ id: 'acme' })
tenant.status                                    // 'provisioning'
// …e os pedidos para ele recebem 503

Nada é 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:

ts
// 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 ​

EstadoServe pedidosComo 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❌ 503O create() escreveu o registo; o trabalho ainda não acabou
failed❌ 503O onProvision lançou. O registo é mantido, não apagado — é a evidência de que o tenant foi tentado
deleting❌ 503O 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_SUSPENDEDFoi a tua app que o escreveu — faturação em atraso, abuso. O tenancy nunca o define
qualquer outro (active, disabled, …)❌ 500 TENANT_STATUS_UNKNOWNUm 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:

ts
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:

ts
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:

ts
// 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:

ts
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():

ts
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 contexto

Isolamento automático ​

Não isolas nada à mão. O mesmo código comporta-se por tenant:

ts
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 tenantId

Scoping 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.

HelperAssinaturaDevolveLança quando
requireTenant()() => TenantO registo completo do tenant do contexto ativoNão há tenant no contexto
requireTenantId(fallback?)(fallback?: string) => stringO id do tenant do contexto; senão o fallbackNã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 últimoNão há tenant no contexto — um tenantId no where nunca é usado como fallback

Os três lançam TenantRequiredError (400 TENANT_REQUIRED).

ts
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 o tenantId em último, pelo que um tenantId infiltrado no where por 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. Um tenantId dentro do where nunca é fallback: o where é muitas vezes construído a partir de input do cliente e, sem tenant resolvido (required é false por omissão), isso deixaria um pedido escolher qualquer tenant omitindo o header de tenant. Por isso tenantScoped({ 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 comando basalt a fixar um tenant (ou embrulha o trabalho em tenancy.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:

ts
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:

ts
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:

ComandoPrecisa deO que faz
basalt tenant:listsource.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>]onMigrateCorre o teu hook de migração por tenant dentro do contexto de cada um
basalt tenant:seed [--tenant=<id>]onSeedCorre 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:

ts
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:

ModoComoIsolamentoQuando
Base de dados partilhadaum client, tenancyExtension() adiciona um filtro tenantIdlógicomaioria das apps; o mais barato de correr
Schema por tenantuma base de dados, um schema PostgreSQL por tenantforteisolamento sem N bases de dados
Base de dados por tenantuma base de dados separada (+ client) por tenanto mais forteconformidade, 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:

ts
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):

ts
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:

ts
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:

ts
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
    },
  }),
])
bash
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çãoTipoPredefiniçãoPropósito
sourceTenantSource— (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
resolversTenantResolver[]— (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
requiredboolean | { except: (string | RegExp)[] }falseRejeita 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) => booleanisValidTenantIdA 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ábricaOpçãoTipoPredefiniçãoPropósito
subdomainResolver({ base })basestring— (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 })headerstring'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 })paramstring'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çãoTipoPredefiniçãoPropósito
storeDomainStorenew 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() => numberDate.nowRelógio injetável (testes)
token() => string24 bytes aleatórios, base64urlGerador do token de verificação (testes)
resolveTxt(host) => Promise<string[][]>resolveTxt de node:dns/promisesConsulta DNS usada pelo verify(); substitui-a nos testes
claimTtlMsnumber72 hQuanto 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())
reservedDomainsstring[][]Os domínios da própria plataforma; cada um e todos os seus subdomínios são recusados com DOMAIN_RESERVED
challengeSecretstring—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:

ts
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 ​

ErroCódigoHTTPQuando
TenantRequiredErrorTENANT_REQUIRED400tenantScoped() / requireTenantId() / requireTenant() correram sem tenant no contexto (e, no requireTenantId, sem fallback explícito)
InvalidTenantIdErrorTENANT_ID_INVALID400tenancy.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
TenantResolutionConflictErrorTENANCY_CONFLICT400onConflict: 'error' e dois resolvers carregaram tenants diferentes (p. ex. um x-tenant-id que contradiz o Host)
TenancyNotResolvedErrorTENANCY_NOT_RESOLVED404required: true e nenhum resolver produziu uma referência que carregasse um tenant
TenantNotFoundErrorTENANT_NOT_FOUND500tenancy.run('unknown-id', …), ou forEach() sobre um TenantSource sem list()
TenantNotReadyErrorTENANT_NOT_READY503Um 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
TenantSuspendedErrorTENANT_SUSPENDED403Um pedido resolveu para um tenant com estado suspended. Voltar a tentar não ajuda
TenantStatusUnknownErrorTENANT_STATUS_UNKNOWN500Um 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
TenantCreateUnsupportedErrorTENANT_CREATE_UNSUPPORTED500tenancy.create() numa source que não implementa nem create() nem save() — por exemplo uma baseada num ficheiro de configuração estático
TenantAlreadyExistsErrorTENANT_ALREADY_EXISTS409tenancy.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()
DomainTakenErrorDOMAIN_TAKEN409domains.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
DomainNotFoundErrorDOMAIN_NOT_FOUND404verify / instructions / remove para um domínio que não está registado
DomainForbiddenErrorDOMAIN_FORBIDDEN403Um tenant agiu sobre um domínio pertencente a um tenant diferente
DomainReservedErrorDOMAIN_RESERVED403domains.add() para um domínio em reservedDomains ou um subdomínio dele
InvalidDomainErrorDOMAIN_INVALID400Um domínio que não é um hostname (userinfo, caminho, %, não-ASCII, literal IP)
MissingCacheScopeErrorCACHE_SCOPE_MISSING500Uma leitura/escrita de cache correu sem tenant com a tenancy ativa — vê Caching
NotATeamMemberErrorTEAM_NOT_A_MEMBER403O tenantMembershipPlugin não encontrou filiação do utilizador no tenant resolvido — vê Teams
  • TENANT_REQUIRED num job em segundo plano ou num script — não há resolver fora de um pedido. Envolve o trabalho em tenancy.run(tenantId, …), ou passa o id explicitamente: requireTenantId(job.tenantId).
  • TENANT_REQUIRED numa rota legitimamente central (sign-up, landing page, administração da plataforma) — essas rotas não deviam sequer chamar tenantScoped(). Consulta a tabela central deliberadamente, e declara meta: { tenant: false } na rota em vez de aliviar o required para a app toda — vê o padrão multi-tenant.
  • TENANCY_NOT_RESOLVED embora 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 o domainResolver, um domínio que nunca foi verificado), ou um header enviado para o subdomínio de outro tenant. Confirma com basalt tenant:list.
  • 403 TEAM_NOT_A_MEMBER logo 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() (ou verify(…, { force: true })) falhou e des-verificou-o. Volta a publicar o registo TXT _basalt-verify.<domain>.

Eventos ​

HookPayload
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.

Publicado sob a licença MIT.