Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
@@ -0,0 +1,219 @@
/**
* tenantFeaturesStore.spec.js
*
* Cobertura V#34/V#41 — Opção B2 (plano + override com exceção comercial).
*
* isEnabled DEVE:
* - sem tenantId/key → false (regressão V#34: opt-out por padrão)
* - override negativo → false sempre
* - override positivo → true sempre (exceção comercial)
* - sem override + plano permite → true
* - sem override + plano não permite → false
* - sem override + user entitlement permite → true (fallback solo therapist)
*
* setForTenant DEVE:
* - chamar supabase.rpc('set_tenant_feature_exception', ...) com payload correto
* - propagar erro
* - atualizar cache local em sucesso
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
const rpcMock = vi.fn();
const fromMock = vi.fn();
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: (...args) => fromMock(...args),
rpc: (...args) => rpcMock(...args)
}
}));
// estado controlável do entitlementsStore
const entState = {
loadedForTenant: null,
tenantFeatures: [],
userFeatures: []
};
vi.mock('@/stores/entitlementsStore', () => ({
useEntitlementsStore: () => entState
}));
const { useTenantFeaturesStore } = await import('../tenantFeaturesStore.js');
beforeEach(() => {
setActivePinia(createPinia());
rpcMock.mockReset();
fromMock.mockReset();
entState.loadedForTenant = null;
entState.tenantFeatures = [];
entState.userFeatures = [];
});
describe('isEnabled — guard rails', () => {
it('retorna false sem tenantId nem loadedForTenantId', () => {
const s = useTenantFeaturesStore();
expect(s.isEnabled('patients')).toBe(false);
});
it('retorna false sem key', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = { patients: true };
s.loadedForTenantId = 't1';
expect(s.isEnabled('')).toBe(false);
expect(s.isEnabled(null, 't1')).toBe(false);
});
it('regressão V#34: NÃO retorna true por padrão quando feature ausente', () => {
// Antes (bug): sem entry em tenant_features → isEnabled = true
// Agora (B2): sem entry + plano não permite → false
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = {}; // sem entries
s.loadedForTenantId = 't1';
// entitlements vazio → não permite
expect(s.isEnabled('patients', 't1')).toBe(false);
});
});
describe('isEnabled — override explícito tem precedência', () => {
it('override positivo → true mesmo se plano não permite (exceção comercial)', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = { 'documents.signatures': true };
s.loadedForTenantId = 't1';
// entitlements vazio para esse tenant
entState.loadedForTenant = 't1';
entState.tenantFeatures = [];
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
});
it('override negativo → false mesmo se plano permite (preferência cliente)', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = { patients: false };
s.loadedForTenantId = 't1';
entState.loadedForTenant = 't1';
entState.tenantFeatures = ['patients'];
expect(s.isEnabled('patients', 't1')).toBe(false);
});
});
describe('isEnabled — sem override → segue plano', () => {
it('plano (tenant entitlement) permite → true', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = {};
s.loadedForTenantId = 't1';
entState.loadedForTenant = 't1';
entState.tenantFeatures = ['patients', 'agenda.view'];
expect(s.isEnabled('patients', 't1')).toBe(true);
expect(s.isEnabled('agenda.view', 't1')).toBe(true);
});
it('plano não permite → false', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = {};
s.loadedForTenantId = 't1';
entState.loadedForTenant = 't1';
entState.tenantFeatures = ['patients'];
expect(s.isEnabled('documents.signatures', 't1')).toBe(false);
});
it('fallback user entitlement (solo therapist) → true', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = {};
s.loadedForTenantId = 't1';
// tenant não tem subscription, mas user tem (therapist_pro)
entState.loadedForTenant = null;
entState.tenantFeatures = [];
entState.userFeatures = ['patients.manage', 'documents.signatures'];
expect(s.isEnabled('patients.manage', 't1')).toBe(true);
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
});
it('entitlements carregado para OUTRO tenant → ignora tenantFeatures (fail-safe)', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = {};
s.loadedForTenantId = 't1';
// ent carregado para t2, não t1 → não confiar em tenantFeatures
entState.loadedForTenant = 't2';
entState.tenantFeatures = ['patients'];
// userFeatures vazio também
expect(s.isEnabled('patients', 't1')).toBe(false);
});
});
describe('setForTenant — RPC obrigatória', () => {
it('rejeita sem tenantId', async () => {
const s = useTenantFeaturesStore();
await expect(s.setForTenant(null, 'patients', true)).rejects.toThrow(/tenantId/);
});
it('rejeita sem key', async () => {
const s = useTenantFeaturesStore();
await expect(s.setForTenant('t1', '', true)).rejects.toThrow(/feature_key/);
});
it('chama RPC set_tenant_feature_exception com payload correto', async () => {
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
const s = useTenantFeaturesStore();
await s.setForTenant('t1', 'patients', false, { reason: 'pref do cliente' });
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
p_tenant_id: 't1',
p_feature_key: 'patients',
p_enabled: false,
p_reason: 'pref do cliente'
});
});
it('aceita reason omitido (vira null)', async () => {
rpcMock.mockResolvedValue({ data: null, error: null });
const s = useTenantFeaturesStore();
await s.setForTenant('t1', 'patients', true);
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
p_tenant_id: 't1',
p_feature_key: 'patients',
p_enabled: true,
p_reason: null
});
});
it('propaga erro do RPC', async () => {
rpcMock.mockResolvedValue({ data: null, error: new Error('Apenas saas_admin pode liberar feature fora do plano') });
const s = useTenantFeaturesStore();
await expect(s.setForTenant('t1', 'documents.signatures', true)).rejects.toThrow(/saas_admin/);
expect(s.lastError).toBeTruthy();
});
it('atualiza cache local em sucesso', async () => {
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
const s = useTenantFeaturesStore();
await s.setForTenant('t1', 'patients', false, { reason: 'test' });
// cache local refletiu
expect(s.featuresByTenant['t1']?.patients).toBe(false);
expect(s.loadedForTenantId).toBe('t1');
});
});
describe('invalidate — limpeza por tenant', () => {
it('sem tenantId limpa tudo', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = { patients: true };
s.featuresByTenant['t2'] = { rooms: false };
s.loadedForTenantId = 't1';
s.invalidate();
expect(Object.keys(s.featuresByTenant).length).toBe(0);
expect(s.loadedForTenantId).toBe(null);
});
it('com tenantId limpa só esse', () => {
const s = useTenantFeaturesStore();
s.featuresByTenant['t1'] = { patients: true };
s.featuresByTenant['t2'] = { rooms: false };
s.loadedForTenantId = 't1';
s.invalidate('t1');
expect(s.featuresByTenant['t1']).toBeUndefined();
expect(s.featuresByTenant['t2']).toEqual({ rooms: false });
expect(s.loadedForTenantId).toBe(null);
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* tenantStore.spec.js — T#5
*
* Cobre os caminhos críticos do tenantStore:
* - ensureLoaded singleflight (não dispara load duplicado)
* - loadSessionAndTenant: usuário não logado, com memberships, com saved
* tenant válido/inválido, fallback ao primeiro active
* - setActiveTenant: tenant válido vs inválido (regressão V#5: tenant_id
* de outro usuário não pode ser herdado)
* - reset: limpa state e localStorage
* - getters: tenantId / currentTenantId / tenant / hasActiveTenant
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
// Stub mínimo de localStorage (vitest roda em env "node" sem jsdom).
// Mantém Map em memória, mesmo contrato síncrono.
const lsBacking = new Map();
globalThis.localStorage = {
getItem: (k) => (lsBacking.has(k) ? lsBacking.get(k) : null),
setItem: (k, v) => lsBacking.set(k, String(v)),
removeItem: (k) => lsBacking.delete(k),
clear: () => lsBacking.clear(),
key: (i) => Array.from(lsBacking.keys())[i] ?? null,
get length() {
return lsBacking.size;
}
};
const getSessionMock = vi.fn();
const rpcMock = vi.fn();
vi.mock('@/lib/supabase/client', () => ({
supabase: {
auth: { getSession: (...a) => getSessionMock(...a) },
rpc: (...a) => rpcMock(...a)
}
}));
vi.mock('@/support/supportLogger', () => ({
logTenant: vi.fn(),
logError: vi.fn()
}));
const { useTenantStore } = await import('../tenantStore.js');
beforeEach(() => {
setActivePinia(createPinia());
getSessionMock.mockReset();
rpcMock.mockReset();
localStorage.clear();
});
const USER = { id: 'u-1', email: 'user@test.com' };
const SESSION = { user: USER };
const memActive = (tenantId, role = 'tenant_admin', kind = 'clinic') => ({
tenant_id: tenantId,
role,
kind,
status: 'active'
});
describe('loadSessionAndTenant — usuário não autenticado', () => {
it('limpa estado quando não há sessão', async () => {
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.loaded).toBe(true);
expect(s.user).toBe(null);
expect(s.activeTenantId).toBe(null);
expect(s.activeRole).toBe(null);
expect(s.memberships).toEqual([]);
expect(s.needsTenantLink).toBe(false);
// não chamou my_tenants sem user
expect(rpcMock).not.toHaveBeenCalled();
});
});
describe('loadSessionAndTenant — usuário com memberships', () => {
it('seleciona primeiro active quando não há saved', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.activeTenantId).toBe('t1');
expect(s.activeRole).toBe('clinic_admin');
expect(localStorage.getItem('tenant_id')).toBe('t1');
expect(s.needsTenantLink).toBe(false);
});
it('respeita saved tenant se pertencer ao usuário (membership active)', async () => {
localStorage.setItem('tenant_id', 't2');
localStorage.setItem('tenant', JSON.stringify({ id: 't2', role: 'clinic_admin' }));
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.activeTenantId).toBe('t2');
});
it('regressão V#5: descarta saved tenant que NÃO pertence ao usuário e cai no primeiro active', async () => {
localStorage.setItem('tenant_id', 't-foreign');
localStorage.setItem('tenant', JSON.stringify({ id: 't-foreign', role: 'clinic_admin' }));
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.activeTenantId).toBe('t1');
// localStorage foi atualizado pro tenant correto
expect(localStorage.getItem('tenant_id')).toBe('t1');
});
it('needsTenantLink=true quando usuário não tem nenhum membership active', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [{ ...memActive('t1'), status: 'pending' }], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.activeTenantId).toBe(null);
expect(s.needsTenantLink).toBe(true);
});
it('normaliza role via roleNormalizer (tenant_admin + kind=therapist → therapist)', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1', 'tenant_admin', 'therapist')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.activeRole).toBe('therapist');
});
});
describe('ensureLoaded — singleflight', () => {
it('não recarrega se já loaded', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
await s.ensureLoaded();
await s.ensureLoaded();
await s.ensureLoaded();
// só uma chamada de getSession
expect(getSessionMock).toHaveBeenCalledTimes(1);
});
it('chamadas concorrentes compartilham a mesma promise', async () => {
let resolveSession;
const sessionPromise = new Promise((res) => {
resolveSession = res;
});
getSessionMock.mockReturnValue(sessionPromise);
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
const p1 = s.ensureLoaded();
const p2 = s.ensureLoaded();
const p3 = s.ensureLoaded();
// resolve a única getSession
resolveSession({ data: { session: SESSION }, error: null });
await Promise.all([p1, p2, p3]);
expect(getSessionMock).toHaveBeenCalledTimes(1);
expect(rpcMock).toHaveBeenCalledTimes(1);
});
});
describe('loadSessionAndTenant — erros', () => {
it('erro em getSession seta error e marca loaded', async () => {
getSessionMock.mockResolvedValue({ data: null, error: new Error('session fail') });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.error).toBeTruthy();
expect(s.loaded).toBe(true);
});
it('erro em my_tenants seta error e mantém estado pré-existente', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: null, error: new Error('rpc fail') });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.error).toBeTruthy();
expect(s.user).toEqual(USER); // user já tinha sido setado antes do throw
expect(s.loaded).toBe(true);
});
});
describe('setActiveTenant — troca explícita', () => {
it('aceita tenant válido (membership active)', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2', 'tenant_admin', 'therapist')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
s.setActiveTenant('t2');
expect(s.activeTenantId).toBe('t2');
expect(s.activeRole).toBe('therapist');
expect(localStorage.getItem('tenant_id')).toBe('t2');
});
it('rejeita tenant que não está nos memberships (sem mudar nada → null)', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
s.setActiveTenant('t-foreign');
expect(s.activeTenantId).toBe(null);
expect(s.activeRole).toBe(null);
expect(s.needsTenantLink).toBe(true);
expect(localStorage.getItem('tenant_id')).toBe(null);
});
});
describe('reset — limpeza completa', () => {
it('zera estado e localStorage', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
s.reset();
expect(s.user).toBe(null);
expect(s.activeTenantId).toBe(null);
expect(s.activeRole).toBe(null);
expect(s.memberships).toEqual([]);
expect(s.loaded).toBe(false);
expect(localStorage.getItem('tenant_id')).toBe(null);
});
});
describe('getters', () => {
it('tenantId / currentTenantId / role expõem activeTenantId / activeRole', async () => {
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
const s = useTenantStore();
await s.loadSessionAndTenant();
expect(s.tenantId).toBe('t1');
expect(s.currentTenantId).toBe('t1');
expect(s.role).toBe('clinic_admin');
expect(s.tenant).toEqual({ id: 't1', role: 'clinic_admin' });
expect(s.hasActiveTenant).toBe(true);
});
it('tenant=null e hasActiveTenant=false sem activeTenantId', () => {
const s = useTenantStore();
expect(s.tenant).toBe(null);
expect(s.hasActiveTenant).toBe(false);
});
});
+18 -4
View File
@@ -14,8 +14,21 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// ─────────────────────────────────────────────────────────────────────────
// entitlementsStore — features que o PLANO PERMITE (read-only, derivado).
//
// Separação V#41 (Opção B2):
// entitlementsStore.has(key) = "o plano permite essa feature?"
// tenantFeaturesStore.isEnabled(k) = "essa feature está ATIVA agora?"
// (combina plano + override)
//
// Source: views v_tenant_entitlements (plano da clínica) e v_user_entitlements
// (assinatura pessoal — solo therapist/supervisor). has() sem scope = união.
// ─────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia';
import { supabase } from '@/lib/supabase/client';
import { logError } from '@/support/supportLogger';
function normalizeKey(k) {
return String(k || '').trim();
@@ -131,11 +144,12 @@ export const useEntitlementsStore = defineStore('entitlements', {
this.loadedForTenant = tenantId;
this.tenantLoadedAt = Date.now();
} catch (e) {
// V#42: NÃO marcar como carregado em caso de erro — força próximo
// request a tentar de novo em vez de mascarar com array vazio.
this.tenantError = e;
this.tenantRaw = [];
this.tenantFeatures = [];
this.loadedForTenant = tenantId;
this.tenantLoadedAt = Date.now();
logError('entitlementsStore.loadForTenant', `tenantId=${tenantId}`, e);
} finally {
this.tenantLoading = false;
}
@@ -171,11 +185,11 @@ export const useEntitlementsStore = defineStore('entitlements', {
this.loadedForUser = userId;
this.userLoadedAt = Date.now();
} catch (e) {
// V#42: mesmo tratamento de loadForTenant — não marca como carregado.
this.userError = e;
this.userRaw = [];
this.userFeatures = [];
this.loadedForUser = userId;
this.userLoadedAt = Date.now();
logError('entitlementsStore.loadForUser', `userId=${userId}`, e);
} finally {
this.userLoading = false;
}
+89 -14
View File
@@ -14,9 +14,28 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// ─────────────────────────────────────────────────────────────────────────
// tenantFeaturesStore — overrides por tenant ("o que está ATIVO agora?")
//
// Funciona em conjunto com entitlementsStore ("o que o plano PERMITE?").
// V#34/V#41 — Opção B2:
// feature ATIVA quando:
// plano permite AND nenhum override negativo, OU
// override positivo (exceção comercial liberada por saas_admin via RPC)
//
// Toda escrita passa pela RPC set_tenant_feature_exception (autorização +
// log + bypass controlado do trigger guard).
// ─────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
// V#18 — TTL configurável (default 5min). Força refetch se cache for mais
// velho que TTL. Antes lastFetchedAt era registrado mas nunca verificado.
const DEFAULT_TTL_MS = 5 * 60 * 1000;
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
const loading = ref(false);
@@ -26,6 +45,9 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
const featuresByTenant = ref({});
// V#18 — timestamps por tenant pra TTL granular
const fetchedAtByTenant = ref({});
// Marca o último tenant buscado (útil pra debug)
const loadedForTenantId = ref(null);
@@ -34,15 +56,43 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
return featuresByTenant.value?.[tenantId] || {};
}
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
// Só retorna false quando explicitamente desabilitada no banco.
/**
* isEnabled — feature está ATIVA para o tenant?
*
* Combina plano (entitlementsStore) + override (tenant_features):
* - override negativo (false) → INATIVO sempre
* - override positivo (true) → ATIVO sempre (exceção comercial)
* - sem override → segue plano
*
* Pré-condição: entitlementsStore deve estar carregado para o mesmo
* tenant (loadForTenant) ou para o user (loadForUser, fallback p/ solo
* therapist). Se não estiver, retorna false (fail-safe).
*/
function isEnabled(key, tenantId = null) {
const tid = tenantId || loadedForTenantId.value;
if (!tid) return false;
if (!tid || !key) return false;
const map = getTenantMap(tid);
if (!(key in map)) return true; // não configurada = habilitada por padrão
return !!map[key];
// Override explícito tem precedência
if (key in map) {
return !!map[key];
}
// Sem override → consulta plano
const ent = useEntitlementsStore();
// Tenant entitlements têm prioridade quando carregado para esse tenant
if (ent.loadedForTenant === tid && Array.isArray(ent.tenantFeatures) && ent.tenantFeatures.includes(key)) {
return true;
}
// Fallback: assinatura pessoal (solo therapist)
if (Array.isArray(ent.userFeatures) && ent.userFeatures.includes(key)) {
return true;
}
return false;
}
function invalidate(tenantId = null) {
@@ -50,20 +100,29 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
if (!tenantId) {
loadedForTenantId.value = null;
featuresByTenant.value = {};
fetchedAtByTenant.value = {};
return;
}
// invalida apenas um tenant
const copy = { ...featuresByTenant.value };
delete copy[tenantId];
featuresByTenant.value = copy;
const tCopy = { ...fetchedAtByTenant.value };
delete tCopy[tenantId];
fetchedAtByTenant.value = tCopy;
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null;
}
async function fetchForTenant(tenantId, { force = false } = {}) {
async function fetchForTenant(tenantId, { force = false, maxAgeMs = DEFAULT_TTL_MS } = {}) {
if (!tenantId) return;
// se já tem cache e não é force, não busca de novo
if (!force && featuresByTenant.value?.[tenantId]) {
// V#18 — usa TTL real: cache válido apenas se age < maxAgeMs
const cached = featuresByTenant.value?.[tenantId];
const fetchedAt = fetchedAtByTenant.value?.[tenantId] || 0;
const age = Date.now() - fetchedAt;
const cacheValid = !force && cached && (maxAgeMs === 0 || age < maxAgeMs);
if (cacheValid) {
loadedForTenantId.value = tenantId;
return;
}
@@ -83,6 +142,10 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
...featuresByTenant.value,
[tenantId]: map
};
fetchedAtByTenant.value = {
...fetchedAtByTenant.value,
[tenantId]: Date.now()
};
loadedForTenantId.value = tenantId;
lastFetchedAt.value = new Date().toISOString();
@@ -96,27 +159,39 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
}
}
async function setForTenant(tenantId, key, enabled) {
/**
* setForTenant — agora roteia tudo via RPC set_tenant_feature_exception.
* Autorização e validação ficam no servidor (V#34 Opção B2):
* - tenant_admin pode desligar feature do plano e religar
* - saas_admin pode tudo + override positivo fora do plano (reason obrigatório)
*/
async function setForTenant(tenantId, key, enabled, { reason = null } = {}) {
if (!tenantId) throw new Error('tenantId missing');
if (!key) throw new Error('feature_key missing');
lastError.value = null;
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled };
const { error } = await supabase.from('tenant_features').upsert(payload, { onConflict: 'tenant_id,feature_key' });
const { data, error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: tenantId,
p_feature_key: key,
p_enabled: !!enabled,
p_reason: reason
});
if (error) {
lastError.value = error;
throw error;
}
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
// Atualiza cache local do tenant
const current = getTenantMap(tenantId);
featuresByTenant.value = {
...featuresByTenant.value,
[tenantId]: { ...current, [key]: !!enabled }
};
loadedForTenantId.value = tenantId;
return data;
}
// (opcional) útil pra debug rápido na tela
+23 -34
View File
@@ -16,30 +16,12 @@
*/
import { defineStore } from 'pinia';
import { supabase } from '@/lib/supabase/client';
import { normalizeRole } from '@/utils/roleNormalizer';
import { logTenant, logError } from '@/support/supportLogger';
/**
* Normaliza o role de tenant levando em conta o kind do tenant.
*
* Regras:
* tenant_admin / admin + kind = 'therapist' → 'therapist'
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (padrão legado)
* qualquer outro role → pass-through
*/
// wrapper que mantém o contrato original do tenantStore (retornar null quando role vazio)
function normalizeTenantRole(role, kind) {
const r = String(role || '').trim();
if (!r) return null;
const isAdmin = r === 'tenant_admin' || r === 'admin';
if (isAdmin) {
const k = String(kind || '').trim();
if (k === 'therapist' || k === 'saas') return 'therapist';
if (k === 'supervisor') return 'supervisor';
return 'clinic_admin';
}
return r;
return normalizeRole(role, kind) || null;
}
function readSavedTenant() {
@@ -65,6 +47,10 @@ function clearPersistedTenant() {
localStorage.removeItem('tenant');
}
// Promise compartilhada: enquanto um loadSessionAndTenant está em voo, todos os
// callers concorrentes aguardam a mesma promise (singleflight).
let loadPromise = null;
export const useTenantStore = defineStore('tenant', {
state: () => ({
loading: false,
@@ -88,22 +74,25 @@ export const useTenantStore = defineStore('tenant', {
actions: {
async ensureLoaded() {
if (this.loaded) return;
if (this.loading) {
await new Promise((resolve) => {
const t = setInterval(() => {
if (!this.loading) {
clearInterval(t);
resolve();
}
}, 50);
});
if (loadPromise) {
await loadPromise;
return;
}
await this.loadSessionAndTenant();
},
async loadSessionAndTenant() {
if (this.loading) return;
if (loadPromise) return loadPromise;
loadPromise = this._doLoadSessionAndTenant();
try {
await loadPromise;
} finally {
loadPromise = null;
}
},
async _doLoadSessionAndTenant() {
this.loading = true;
this.error = null;
@@ -139,7 +128,7 @@ export const useTenantStore = defineStore('tenant', {
if (savedTenantId) {
activeMembership = this.memberships.find((x) => x.tenant_id === savedTenantId && x.status === 'active');
if (!activeMembership) {
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.');
logTenant('tenantStore', 'tenant salvo não pertence a este usuário, limpando', { savedTenantId });
clearPersistedTenant();
}
}
@@ -161,7 +150,7 @@ export const useTenantStore = defineStore('tenant', {
this.needsTenantLink = !this.activeTenantId;
this.loaded = true;
} catch (e) {
console.warn('[tenantStore] loadSessionAndTenant falhou:', e);
logError('tenantStore', 'loadSessionAndTenant falhou', e);
this.error = e;
if (!this.user) {