feat: implement authentication and RBAC

- packages/auth: Argon2id password hashing, JWT access tokens (jose),
  opaque refresh tokens with rotation, generic error messages (no
  user-enumeration via timing or message differences)
- roles/permissions/role_permissions/user_roles/sessions/audit_logs schema
  (agente.md secoes 142-150); RBAC scope PLATFORM vs TENANT
- withUserContext(): narrow RLS exception so a user can discover their own
  tenant_memberships before a tenant is chosen (login flow)
- userHasPermission()/isPlatformUser(): explicit service-layer RBAC checks
  (roles/permissions tables are not RLS-protected — documented why in
  docs/AUTHENTICATION.md)
- seed: permission catalog, 4 system roles, initial Platform Super Admin
  (password written once to FIRST_LOGIN.txt, 600, outside Git)
- automated end-to-end test: login, RBAC check, refresh rotation, logout
This commit is contained in:
2026-08-28 05:58:49 -03:00
parent d66170c795
commit 70c5586595
16 changed files with 1172 additions and 10 deletions

13
TODO.md
View File

@@ -29,8 +29,17 @@
- [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`) - [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`)
## PHASE 04 — Authentication / RBAC ## PHASE 04 — Authentication / RBAC
- [ ] Login (Argon2id), access/refresh tokens - [x] `packages/auth`: hash Argon2id (`@node-rs/argon2`), JWT access token (`jose`),
- [ ] roles/permissions/user_roles/role_permissions refresh token opaco com rotation
- [x] Tabelas `roles`, `permissions`, `role_permissions`, `user_roles`, `sessions`, `audit_logs`
- [x] `login()` / `refreshSession()` / `logout()` / `listUserTenants()` / `setActiveTenant()`
- [x] `userHasPermission()` (RBAC com scope PLATFORM/TENANT)
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
- [ ] Camada HTTP (endpoints, rate limit por IP, guards) — depende de `apps/api` existir,
ver docs/AUTHENTICATION.md → "O que falta"
- [ ] Password reset por e-mail — depende de SMTP configurado
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center, ## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)

88
docs/AUTHENTICATION.md Normal file
View File

@@ -0,0 +1,88 @@
# Autenticação e RBAC
Implementado em `packages/auth`, sobre o schema criado pela migration
`auth_and_rbac` (`packages/database/prisma/schema.prisma`).
## Login (agente.md secao 148)
- Senha: Argon2id via `@node-rs/argon2` (binário pré-compilado, sem node-gyp),
parâmetros padrão OWASP (memoryCost 19 MiB, timeCost 2, parallelism 1).
- `login()` sempre roda `verify()` contra um hash Argon2id fixo mesmo quando o
e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`)
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
de resposta ou mensagem.
- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId,
tenantId? }`.
- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
é a prova de identidade.
- Refresh rotation: cada uso de `refreshSession()` troca o hash guardado na
mesma linha de `sessions`; o token anterior para de bater com qualquer hash
no banco imediatamente.
- Logout / revogação: `sessions.revoked_at` — idempotente.
- `mustChangePassword` no `User`: setado `true` no Platform Super Admin
inicial (seed); a aplicação (quando existir a camada HTTP) deve forçar troca
de senha antes de liberar qualquer outra rota quando essa flag estiver ativa.
## Resolução de tenant (agente.md secao 31)
Login não recebe nem decide `tenant_id`. O fluxo é:
1. `login()` autentica só por e-mail/senha — devolve tokens sem tenant.
2. `listUserTenants(userId)` devolve os tenants aos quais o usuário pertence
(via `tenant_memberships`), usando `withUserContext` — a única exceção
documentada de RLS "olhar os próprios dados sem tenant ainda escolhido"
(ver docs/TENANT_ISOLATION.md).
3. `setActiveTenant(sessionId, userId, tenantId)` valida a membership de novo
(nunca confia em `tenantId` vindo do cliente sem checar) e emite um novo
access token já com `tenantId` nas claims.
## RBAC (agente.md secoes 142-145)
Tabelas: `roles`, `permissions`, `role_permissions`, `user_roles`.
- `Role.scope`: `PLATFORM` (vale em qualquer tenant, `UserRole.tenantId =
null`) ou `TENANT` (vale só no tenant especificado em `UserRole.tenantId`).
- `userHasPermission(userId, permissionKey, tenantId?)`: junta roles PLATFORM
do usuário com as roles TENANT do tenant informado, e checa se alguma delas
carrega a permission. Chamado explicitamente pela camada de serviço — **não
depende de RLS** (ver próxima seção).
- Seed (`packages/auth/src/seed.ts`, agente.md secao 200) cria o catálogo de
permissions e 4 roles de sistema: `platform_super_admin` (PLATFORM, todas as
permissions), `tenant_admin`, `supervisor`, `agent` (TENANT, mapeamento
inicial documentado no próprio seed — ajustar quando existir UI de RBAC).
## Por que roles/permissions/user_roles/sessions/audit_logs NÃO têm RLS
Diferente das tabelas de negócio tenant-scoped (extensions, agents, campaigns,
calls, ...), essas tabelas são infraestrutura de autenticação/autorização,
tocadas exclusivamente pelo código confiável de `packages/auth`, que já
resolve o filtro de tenant explicitamente em cada query (`userHasPermission`,
`setActiveTenant`, etc.). Isso é a camada "RBAC + object authorization" da
defesa em profundidade da seção 32 do `agente.md` — complementar à RLS, não
substituída por ela. Se no futuro essas tabelas passarem a ser expostas por
queries genéricas (ex.: um endpoint de admin que aceita filtros arbitrários),
revisitar essa decisão e considerar RLS ali também.
## Platform Super Admin inicial (agente.md secao 199)
O seed cria `admin@b2bcall.local` com senha aleatória de 24 bytes, salva uma
única vez em `FIRST_LOGIN.txt` (permissão 600, fora do Git) com
`mustChangePassword = true`. Rodar de novo o seed não recria o admin se já
existir um usuário com role `platform_super_admin`.
## O que falta (fica para quando existir `apps/api`)
Tudo abaixo depende de uma camada HTTP (NestJS/Fastify) que ainda não existe:
- Rate limiting de login por IP/usuário (agente.md secao 149) — plugin
`@fastify/rate-limit` ou equivalente, não implementável em `packages/auth`
isoladamente.
- Endpoints REST (`POST /auth/login`, `/auth/refresh`, `/auth/logout`,
`/auth/select-tenant`) e guards HTTP que traduzem `InvalidCredentialsError`/
`NotATenantMemberError` em 401/403.
- Password reset (link expirável por e-mail) — precisa de um provedor de
e-mail/SMTP, fora do escopo desta fase.
- Reuse detection de refresh token roubado (família de tokens) — não
implementado; a rotação simples (secao 148) já está feita, a detecção de
reuso é um hardening adicional a avaliar depois.

View File

@@ -0,0 +1,21 @@
{
"name": "@b2bcall/auth",
"version": "0.0.1",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"seed": "tsx src/seed.ts",
"test:auth": "tsx src/__tests__/auth.test.ts"
},
"dependencies": {
"@b2bcall/database": "workspace:*",
"@node-rs/argon2": "^2.1.0",
"jose": "^6.2.10"
},
"devDependencies": {
"tsx": "^4.23.12",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,110 @@
/**
* Verificação end-to-end do fluxo de autenticação (agente.md secao 148):
* login, refresh rotation, logout/revogação, e checagem de RBAC.
*
* Roda com: pnpm --filter @b2bcall/auth run test:auth
*/
import { randomUUID } from "node:crypto";
import { getPrismaClient } from "@b2bcall/database";
import { hashPassword } from "../password";
import {
InvalidCredentialsError,
InvalidRefreshTokenError,
login,
logout,
refreshSession,
} from "../session";
import { userHasPermission } from "../permissions";
function assert(condition: boolean, message: string): void {
if (!condition) {
throw new Error(`FALHOU: ${message}`);
}
console.log(`OK: ${message}`);
}
async function assertThrows(fn: () => Promise<unknown>, ErrorClass: new (...a: any[]) => Error, message: string) {
try {
await fn();
throw new Error(`FALHOU: ${message} (nao lancou erro)`);
} catch (err) {
assert(err instanceof ErrorClass, message);
}
}
async function main() {
const prisma = getPrismaClient();
const suffix = randomUUID().slice(0, 8);
const email = `auth-test-${suffix}@test.local`;
const password = "S3nhaForteDeTeste!123";
const tenant = await prisma.tenant.create({
data: { code: `auth-test-${suffix}`, slug: `auth-test-${suffix}`, legalName: "Auth Test LTDA" },
});
const user = await prisma.user.create({
data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" },
});
const agentRole = await prisma.role.findUniqueOrThrow({ where: { key: "agent" } });
await prisma.userRole.create({
data: { userId: user.id, roleId: agentRole.id, tenantId: tenant.id },
});
// Login com senha errada / e-mail inexistente: mesmo erro genérico.
await assertThrows(
() => login({ email, password: "senha-errada" }),
InvalidCredentialsError,
"Senha errada rejeitada com erro genérico",
);
await assertThrows(
() => login({ email: `nao-existe-${suffix}@test.local`, password }),
InvalidCredentialsError,
"E-mail inexistente rejeitado com o MESMO erro genérico (sem user-enumeration)",
);
// Login correto.
const result = await login({ email, password, ipAddress: "127.0.0.1", userAgent: "test-agent" });
assert(!result.mustChangePassword, "Usuário de teste não precisa trocar senha");
assert(Boolean(result.accessToken) && Boolean(result.refreshToken), "Login retorna access e refresh token");
// RBAC: agent tem dashboard.view, não tem tenants.manage.
assert(
await userHasPermission(user.id, "dashboard.view", tenant.id),
"Agent tem permissão dashboard.view no próprio tenant",
);
assert(
!(await userHasPermission(user.id, "tenants.manage", tenant.id)),
"Agent NÃO tem permissão tenants.manage",
);
// Refresh rotation: token antigo morre assim que um novo é emitido.
const refreshed = await refreshSession(result.refreshToken);
assert(Boolean(refreshed.accessToken), "Refresh emite novo access token");
assert(refreshed.refreshToken !== result.refreshToken, "Refresh token rotacionado é diferente do anterior");
await assertThrows(
() => refreshSession(result.refreshToken),
InvalidRefreshTokenError,
"Refresh token antigo (já rotacionado) não funciona mais",
);
// Logout revoga a sessão — refresh subsequente falha.
await logout(result.sessionId);
await assertThrows(
() => refreshSession(refreshed.refreshToken),
InvalidRefreshTokenError,
"Refresh token de sessão revogada (logout) não funciona",
);
// Limpeza.
await prisma.userRole.deleteMany({ where: { userId: user.id } });
await prisma.session.deleteMany({ where: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
await prisma.tenant.delete({ where: { id: tenant.id } });
console.log("\nFluxo de autenticação OK: login, RBAC, refresh rotation e logout funcionam.");
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,29 @@
import type { Prisma, PrismaClient } from "@b2bcall/database";
export interface AuditEvent {
action: string;
tenantId?: string | null;
userId?: string | null;
entityType?: string;
entityId?: string;
before?: Prisma.InputJsonValue;
after?: Prisma.InputJsonValue;
ipAddress?: string;
userAgent?: string;
}
export async function recordAuditEvent(prisma: PrismaClient, event: AuditEvent): Promise<void> {
await prisma.auditLog.create({
data: {
action: event.action,
tenantId: event.tenantId ?? null,
userId: event.userId ?? null,
entityType: event.entityType,
entityId: event.entityId,
before: event.before,
after: event.after,
ipAddress: event.ipAddress,
userAgent: event.userAgent,
},
});
}

View File

@@ -0,0 +1,5 @@
export * from "./password";
export * from "./tokens";
export * from "./audit";
export * from "./session";
export * from "./permissions";

View File

@@ -0,0 +1,17 @@
import { Algorithm, hash, verify } from "@node-rs/argon2";
// OWASP-recommended minimums for Argon2id (agente.md secao 148: Argon2id).
const ARGON2_OPTIONS = {
algorithm: Algorithm.Argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
};
export function hashPassword(plainPassword: string): Promise<string> {
return hash(plainPassword, ARGON2_OPTIONS);
}
export function verifyPassword(passwordHash: string, plainPassword: string): Promise<boolean> {
return verify(passwordHash, plainPassword);
}

View File

@@ -0,0 +1,40 @@
import { getPrismaClient } from "@b2bcall/database";
/**
* Verifica se `userId` tem a permissão `permissionKey`, considerando roles
* PLATFORM (tenantId nulo, valem em qualquer contexto) e roles TENANT
* atribuídas especificamente em `tenantId` (agente.md secao 144: role scope
* PLATFORM ou TENANT — nunca escopo global acidental).
*
* Não usa RLS (ver comentário na migration auth_and_rbac): a checagem é
* feita explicitamente aqui, na camada de serviço confiável.
*/
export async function userHasPermission(
userId: string,
permissionKey: string,
tenantId?: string,
): Promise<boolean> {
const prisma = getPrismaClient();
const where = tenantId
? { userId, OR: [{ tenantId }, { tenantId: null }] }
: { userId, tenantId: null };
const userRoles = await prisma.userRole.findMany({
where,
include: { role: { include: { rolePermissions: { include: { permission: true } } } } },
});
return userRoles.some((userRole) =>
userRole.role.rolePermissions.some((rp) => rp.permission.key === permissionKey),
);
}
/** Atalho: usuário tem QUALQUER role com scope PLATFORM (ex.: platform_super_admin). */
export async function isPlatformUser(userId: string): Promise<boolean> {
const prisma = getPrismaClient();
const count = await prisma.userRole.count({
where: { userId, tenantId: null, role: { scope: "PLATFORM" } },
});
return count > 0;
}

172
packages/auth/src/seed.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Seed de RBAC (agente.md secoes 142, 145, 199, 200): catálogo de
* permissions, roles de sistema, e o Platform Super Admin inicial.
*
* Idempotente — seguro rodar de novo (upsert por chave única). Roda com:
* pnpm --filter @b2bcall/auth run seed
*/
import { randomBytes } from "node:crypto";
import { writeFileSync, chmodSync } from "node:fs";
import { resolve } from "node:path";
import { getPrismaClient } from "@b2bcall/database";
import { hashPassword } from "./password";
// Catálogo completo de permissions (agente.md secao 145).
const PERMISSIONS: Array<{ key: string; description: string }> = [
{ key: "tenants.view", description: "Ver tenants (plataforma)" },
{ key: "tenants.manage", description: "Criar/editar/suspender tenants" },
{ key: "billing.view", description: "Ver consumo e faturas" },
{ key: "billing.manage", description: "Gerenciar fechamentos de billing" },
{ key: "pricing.manage", description: "Gerenciar planos e price books" },
{ key: "dashboard.view", description: "Ver dashboards" },
{ key: "extensions.view", description: "Ver ramais" },
{ key: "extensions.manage", description: "Criar/editar ramais" },
{ key: "trunks.view", description: "Ver troncos" },
{ key: "trunks.manage", description: "Criar/editar troncos" },
{ key: "agents.view", description: "Ver agentes" },
{ key: "agents.manage", description: "Criar/editar agentes" },
{ key: "queues.view", description: "Ver filas" },
{ key: "queues.manage", description: "Criar/editar filas" },
{ key: "campaigns.view", description: "Ver campanhas" },
{ key: "campaigns.create", description: "Criar campanhas" },
{ key: "campaigns.update", description: "Editar campanhas" },
{ key: "campaigns.start", description: "Iniciar campanhas" },
{ key: "campaigns.pause", description: "Pausar campanhas" },
{ key: "campaigns.stop", description: "Parar campanhas" },
{ key: "monitoring.view", description: "Ver monitoramento em tempo real" },
{ key: "reports.view", description: "Ver relatórios" },
{ key: "reports.export", description: "Exportar relatórios" },
{ key: "recordings.view", description: "Ver gravações" },
{ key: "recordings.download", description: "Baixar gravações" },
{ key: "ai.view", description: "Ver análises de IA" },
{ key: "ai.manage", description: "Configurar providers/prompts de IA" },
{ key: "ai.analyze", description: "Disparar análise de IA manualmente" },
{ key: "freeswitch.view", description: "Ver estado do FreeSWITCH" },
{ key: "freeswitch.configure", description: "Configurar FreeSWITCH" },
{ key: "users.manage", description: "Gerenciar usuários" },
{ key: "roles.manage", description: "Gerenciar roles/permissões" },
{ key: "audit.view", description: "Ver audit log" },
];
// Mapeamento inicial role -> permissions. Ponto de partida razoável;
// revisar quando existir uma UI de administração de RBAC.
const ROLE_PERMISSIONS: Record<string, string[]> = {
platform_super_admin: PERMISSIONS.map((p) => p.key), // tudo
tenant_admin: PERMISSIONS.map((p) => p.key).filter(
(key) => !["tenants.view", "tenants.manage", "pricing.manage"].includes(key),
),
supervisor: [
"dashboard.view",
"extensions.view",
"trunks.view",
"agents.view",
"agents.manage",
"queues.view",
"campaigns.view",
"campaigns.update",
"campaigns.start",
"campaigns.pause",
"campaigns.stop",
"monitoring.view",
"reports.view",
"reports.export",
"recordings.view",
"recordings.download",
"ai.view",
],
agent: ["dashboard.view", "campaigns.view"],
};
const SYSTEM_ROLES: Array<{ key: string; name: string; scope: "PLATFORM" | "TENANT" }> = [
{ key: "platform_super_admin", name: "Platform Super Admin", scope: "PLATFORM" },
{ key: "tenant_admin", name: "Tenant Admin", scope: "TENANT" },
{ key: "supervisor", name: "Supervisor", scope: "TENANT" },
{ key: "agent", name: "Agente", scope: "TENANT" },
];
const PLATFORM_ADMIN_EMAIL = "admin@b2bcall.local";
async function main() {
const prisma = getPrismaClient();
for (const permission of PERMISSIONS) {
await prisma.permission.upsert({
where: { key: permission.key },
update: { description: permission.description },
create: permission,
});
}
console.log(`Permissions: ${PERMISSIONS.length} sincronizadas.`);
for (const role of SYSTEM_ROLES) {
const created = await prisma.role.upsert({
where: { key: role.key },
update: { name: role.name, scope: role.scope, isSystem: true },
create: { ...role, isSystem: true },
});
const permissionKeys = ROLE_PERMISSIONS[role.key] ?? [];
const permissions = await prisma.permission.findMany({
where: { key: { in: permissionKeys } },
});
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
if (permissions.length > 0) {
await prisma.rolePermission.createMany({
data: permissions.map((p) => ({ roleId: created.id, permissionId: p.id })),
skipDuplicates: true,
});
}
console.log(`Role '${role.key}': ${permissions.length} permissions.`);
}
const existingPlatformAdmin = await prisma.userRole.findFirst({
where: { tenantId: null, role: { key: "platform_super_admin" } },
});
if (existingPlatformAdmin) {
console.log("Platform Super Admin já existe, pulando criação.");
} else {
const platformSuperAdminRole = await prisma.role.findUniqueOrThrow({
where: { key: "platform_super_admin" },
});
const initialPassword = randomBytes(18).toString("base64url");
const passwordHash = await hashPassword(initialPassword);
const admin = await prisma.user.upsert({
where: { email: PLATFORM_ADMIN_EMAIL },
update: {},
create: {
email: PLATFORM_ADMIN_EMAIL,
passwordHash,
name: "Platform Super Admin",
mustChangePassword: true,
},
});
await prisma.userRole.create({
data: { userId: admin.id, roleId: platformSuperAdminRole.id, tenantId: null },
});
const firstLoginPath = resolve(__dirname, "../../../FIRST_LOGIN.txt");
writeFileSync(
firstLoginPath,
`B2BCall — Platform Super Admin (gerado em ${new Date().toISOString()})\n` +
`Email: ${PLATFORM_ADMIN_EMAIL}\n` +
`Senha: ${initialPassword}\n\n` +
`Troca de senha OBRIGATÓRIA no primeiro login. Apague este arquivo depois de guardar a senha em um local seguro.\n`,
);
chmodSync(firstLoginPath, 0o600);
console.log(`Platform Super Admin criado: ${PLATFORM_ADMIN_EMAIL}`);
console.log(`Senha salva em ${firstLoginPath} (permissao 600) — nao sera exibida no terminal.`);
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,187 @@
import { getPrismaClient, withUserContext } from "@b2bcall/database";
import { recordAuditEvent } from "./audit";
import { verifyPassword } from "./password";
import {
REFRESH_TOKEN_TTL_MS,
generateRefreshToken,
hashRefreshToken,
signAccessToken,
} from "./tokens";
// Hash Argon2id de uma senha aleatória fixa, usado só para manter o tempo de
// resposta do login constante quando o e-mail não existe — evita
// user-enumeration via timing attack. Nunca corresponde a uma senha real.
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=19456,t=2,p=1$ue+qGHKnsPX6ducjF4h61Q$wRqupd7dHkad21UhvTxLuv8bDqNr63dZmuydRkjohr0";
export class InvalidCredentialsError extends Error {
constructor() {
super("E-mail ou senha invalidos");
}
}
export class InvalidRefreshTokenError extends Error {
constructor() {
super("Refresh token invalido, expirado ou revogado");
}
}
export class NotATenantMemberError extends Error {
constructor() {
super("Usuario nao pertence a este tenant");
}
}
export interface LoginParams {
email: string;
password: string;
ipAddress?: string;
userAgent?: string;
}
export interface AuthResult {
accessToken: string;
refreshToken: string;
sessionId: string;
mustChangePassword: boolean;
}
/**
* Login por e-mail/senha (agente.md secao 148). Sempre roda verify() de
* Argon2id mesmo quando o e-mail não existe (contra timing attack), e nunca
* revela via mensagem se foi o e-mail ou a senha que estava errada.
*/
export async function login(params: LoginParams): Promise<AuthResult> {
const prisma = getPrismaClient();
const email = params.email.trim().toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
const passwordOk = await verifyPassword(user?.passwordHash ?? DUMMY_PASSWORD_HASH, params.password);
const isUsable = Boolean(user) && !user!.deletedAt && user!.status === "ACTIVE";
if (!user || !passwordOk || !isUsable) {
await recordAuditEvent(prisma, {
action: "LOGIN_FAILED",
userId: user?.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
after: { email },
});
throw new InvalidCredentialsError();
}
const refreshToken = generateRefreshToken();
const session = await prisma.session.create({
data: {
userId: user.id,
refreshTokenHash: hashRefreshToken(refreshToken),
userAgent: params.userAgent,
ipAddress: params.ipAddress,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({ sub: user.id, sessionId: session.id });
await recordAuditEvent(prisma, {
action: "LOGIN",
userId: user.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
});
return {
accessToken,
refreshToken,
sessionId: session.id,
mustChangePassword: user.mustChangePassword,
};
}
/**
* Rotaciona o refresh token (agente.md secao 148: "refresh rotation"). O
* token antigo para de funcionar assim que um novo é emitido — a linha de
* `sessions` é a mesma, só o hash e a expiração mudam.
*/
export async function refreshSession(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const prisma = getPrismaClient();
const session = await prisma.session.findUnique({
where: { refreshTokenHash: hashRefreshToken(refreshToken) },
});
if (!session || session.revokedAt || session.expiresAt.getTime() < Date.now()) {
throw new InvalidRefreshTokenError();
}
const newRefreshToken = generateRefreshToken();
await prisma.session.update({
where: { id: session.id },
data: {
refreshTokenHash: hashRefreshToken(newRefreshToken),
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({
sub: session.userId,
sessionId: session.id,
tenantId: session.activeTenantId ?? undefined,
});
return { accessToken, refreshToken: newRefreshToken };
}
/** Revoga a sessão (logout / revogação administrativa). Idempotente. */
export async function logout(sessionId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Lista os tenants aos quais o usuário pertence — precisa do contexto
* especial de RLS "olhar as próprias memberships" (withUserContext), já que
* ainda não existe um tenant escolhido nesse ponto do fluxo (ver
* docs/AUTHENTICATION.md e docs/TENANT_ISOLATION.md).
*/
export async function listUserTenants(userId: string) {
const prisma = getPrismaClient();
return withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findMany({
where: { userId },
include: { tenant: true },
}),
);
}
/**
* Troca o tenant ativo de uma sessão já autenticada, validando a membership
* antes de emitir um novo access token com `tenantId` nas claims.
*/
export async function setActiveTenant(
sessionId: string,
userId: string,
tenantId: string,
): Promise<string> {
const prisma = getPrismaClient();
const membership = await withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findUnique({
where: { tenantId_userId: { tenantId, userId } },
}),
);
if (!membership) {
throw new NotATenantMemberError();
}
const session = await prisma.session.update({
where: { id: sessionId },
data: { activeTenantId: tenantId },
});
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
}

View File

@@ -0,0 +1,48 @@
import { createHash, randomBytes } from "node:crypto";
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
const ACCESS_TOKEN_TTL = "15m";
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
export interface AccessTokenClaims extends JWTPayload {
sub: string; // userId
sessionId: string;
tenantId?: string;
}
function getAccessTokenSecret(): Uint8Array {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET nao configurado");
}
return new TextEncoder().encode(secret);
}
export async function signAccessToken(
claims: Omit<AccessTokenClaims, "iat" | "exp">,
): Promise<string> {
return new SignJWT(claims)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(ACCESS_TOKEN_TTL)
.sign(getAccessTokenSecret());
}
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
const { payload } = await jwtVerify(token, getAccessTokenSecret());
return payload as AccessTokenClaims;
}
/**
* Refresh tokens são strings opacas de alta entropia — nunca JWT, nunca
* guardadas em texto puro. O que fica em `sessions.refresh_token_hash` é o
* SHA-256 do token; posse do token original é a prova de identidade, então
* hash reverso não compromete a sessão.
*/
export function generateRefreshToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashRefreshToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,139 @@
-- CreateEnum
CREATE TYPE "role_scope" AS ENUM ('PLATFORM', 'TENANT');
-- AlterTable
ALTER TABLE "users" ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"scope" "role_scope" NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" UUID NOT NULL,
"key" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" UUID NOT NULL,
"permission_id" UUID NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"tenant_id" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"refresh_token_hash" TEXT NOT NULL,
"active_tenant_id" UUID,
"user_agent" TEXT,
"ip_address" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL,
"tenant_id" UUID,
"user_id" UUID,
"action" TEXT NOT NULL,
"entity_type" TEXT,
"entity_id" TEXT,
"before" JSONB,
"after" JSONB,
"ip_address" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "roles_key_key" ON "roles"("key");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_user_id_role_id_tenant_id_key" ON "user_roles"("user_id", "role_id", "tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "audit_logs_tenant_id_created_at_idx" ON "audit_logs"("tenant_id", "created_at");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- The login flow needs to discover which tenants a user belongs to BEFORE a
-- tenant has been chosen (chicken-and-egg: RLS normally requires
-- app.current_tenant_id to already be set). Extend the tenant_isolation
-- policy so a session can also see its OWN memberships via
-- app.current_user_id, without ever exposing other users' memberships or
-- other tenants' data. See docs/AUTHENTICATION.md.
DROP POLICY "tenant_isolation" ON "tenant_memberships";
CREATE POLICY "tenant_isolation" ON "tenant_memberships"
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid
);
-- roles / permissions / role_permissions / user_roles / sessions / audit_logs
-- are intentionally NOT protected by RLS. Unlike tenant business data
-- (extensions, agents, campaigns, calls, ...), these are auth-internal
-- tables touched only by the trusted packages/auth service layer, which
-- applies its own explicit WHERE clauses and RBAC checks (defense-in-depth
-- per agente.md secao 32: RBAC + object authorization + tenant repositories
-- + RLS are complementary layers, not all mandatory for every table).

View File

@@ -33,6 +33,7 @@ model Tenant {
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[] memberships TenantMembership[]
userRoles UserRole[]
@@map("tenants") @@map("tenants")
} }
@@ -47,20 +48,124 @@ enum UserStatus {
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant // Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
// é sempre resolvido via TenantMembership (agente.md secao 31). // é sempre resolvido via TenantMembership (agente.md secao 31).
model User { model User {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
email String @unique email String @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
name String name String
status UserStatus @default(ACTIVE) status UserStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") mustChangePassword Boolean @default(false) @map("must_change_password")
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[] memberships TenantMembership[]
userRoles UserRole[]
sessions Session[]
@@map("users") @@map("users")
} }
enum RoleScope {
PLATFORM
TENANT
@@map("role_scope")
}
// Definições de role. Roles de sistema (isSystem=true) são criadas pelo seed
// (agente.md secao 142) e não podem ser removidas via API.
model Role {
id String @id @default(uuid()) @db.Uuid
key String @unique
name String
scope RoleScope
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
rolePermissions RolePermission[]
userRoles UserRole[]
@@map("roles")
}
model Permission {
id String @id @default(uuid()) @db.Uuid
key String @unique
description String?
rolePermissions RolePermission[]
@@map("permissions")
}
model RolePermission {
roleId String @map("role_id") @db.Uuid
permissionId String @map("permission_id") @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@map("role_permissions")
}
// tenantId é obrigatório quando role.scope == TENANT e deve ser nulo quando
// role.scope == PLATFORM (invariante aplicado em packages/auth, não no banco —
// ver docs/AUTHENTICATION.md).
model UserRole {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
roleId String @map("role_id") @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, roleId, tenantId])
@@map("user_roles")
}
// Refresh-token de longa duração (rotacionado a cada uso). Nunca guardamos o
// token em texto puro — só o hash (SHA-256) usado para lookup/comparação.
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
refreshTokenHash String @unique @map("refresh_token_hash")
activeTenantId String? @map("active_tenant_id") @db.Uuid
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
// tenantId nulo = evento de escopo plataforma (agente.md secao 150).
model AuditLog {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
userId String? @map("user_id") @db.Uuid
action String
entityType String? @map("entity_type")
entityId String? @map("entity_id")
before Json?
after Json?
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId, createdAt])
@@index([userId])
@@map("audit_logs")
}
// Tabela tenant-scoped protegida por Row Level Security (ver migration // Tabela tenant-scoped protegida por Row Level Security (ver migration
// 'tenant_isolation' e docs/TENANT_ISOLATION.md). // 'tenant_isolation' e docs/TENANT_ISOLATION.md).
model TenantMembership { model TenantMembership {

View File

@@ -41,3 +41,22 @@ export async function withTenantContext<T>(
return fn(tx); return fn(tx);
}); });
} }
/**
* Like withTenantContext, but sets `app.current_user_id` instead of a
* tenant. Used only during login/tenant-resolution, when the caller needs to
* see a user's OWN tenant_memberships before any tenant has been chosen. The
* RLS policy on tenant_memberships allows this narrow self-lookup without
* exposing other users' memberships or other tenants' data (see
* docs/AUTHENTICATION.md).
*/
export async function withUserContext<T>(
client: PrismaClient,
userId: string,
fn: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
return client.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
return fn(tx);
});
}

165
pnpm-lock.yaml generated
View File

@@ -12,6 +12,25 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
packages/auth:
dependencies:
'@b2bcall/database':
specifier: workspace:*
version: link:../database
'@node-rs/argon2':
specifier: ^2.1.0
version: 2.1.0
jose:
specifier: ^6.2.10
version: 6.2.10
devDependencies:
tsx:
specifier: ^4.23.12
version: 4.23.12
typescript:
specifier: ^5.7.0
version: 5.9.3
packages/database: packages/database:
dependencies: dependencies:
'@prisma/adapter-pg': '@prisma/adapter-pg':
@@ -239,6 +258,92 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@node-rs/argon2-android-arm-eabi@2.1.0':
resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@node-rs/argon2-android-arm64@2.1.0':
resolution: {integrity: sha512-MHby1n9UzlVma3GiEYWNNqRhn2Z/90QwF2PFckGwucdz0HfdVX+lSKEcyfwcbvS5sJ9GAzhop3hXlUPxUYe72Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@node-rs/argon2-darwin-arm64@2.1.0':
resolution: {integrity: sha512-5nayvL9wepOvTO5JmdaiDZ6bgHEsI/vksnXphM3UTy/cflARr9MoD1pBrMPs921hx2otURJhG0avg9hN0Q0KPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@node-rs/argon2-darwin-x64@2.1.0':
resolution: {integrity: sha512-7bFf3qH/PLQ5kU5GkxMF+dMz/tiT+0yFXILpOx4fgSFjw3wdXpGZt3wH4i6dHMURDcwYlNap0hJaX4h2E3JGng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@node-rs/argon2-freebsd-x64@2.1.0':
resolution: {integrity: sha512-01mVdl+7bjSW0to5xcEzueq7BuYbz3476/dmLMXziJf8fhykzr723YRNcGjOcHSU5fxNrElY9uVeUoow16vBrQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@node-rs/argon2-linux-arm-gnueabihf@2.1.0':
resolution: {integrity: sha512-NxZILF+Hqav6yzJTUFuZXTUC1VagTO4Vfd2HSlzkwankUa1B7goo0NLY2tUPw6MdqtLojkqVG5MZ26AaF49tHw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@node-rs/argon2-linux-arm64-gnu@2.1.0':
resolution: {integrity: sha512-jpx2VwJLyAF/cM64HRigFBk67GwtQBcssYyVhvHqfmCEZz/CP//SV9bxE+uJOGIPKJo7wDg/5f2k3JY0PNMROg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@node-rs/argon2-linux-arm64-musl@2.1.0':
resolution: {integrity: sha512-xXAaPUlnPx/44qwaSekTG/g6/h5QCyrTayPLmvewJNyYz17vyBRmFKWLgv9Q3+NJwSvV88nVnCu/xkQbEPQaog==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@node-rs/argon2-linux-x64-gnu@2.1.0':
resolution: {integrity: sha512-ICnfyFaxZzr8OdAEwTPsAjx6gpNla1sI3miZJWAVCtZlw98Wo3Wd4H7tvW6S2NRJ1BIOf4l8Z7aX7h3fimXn5w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@node-rs/argon2-linux-x64-musl@2.1.0':
resolution: {integrity: sha512-f56jehb/IPTGBWWrvMjDGZWlQm/hKkH2VK/MxJ9u3kUR9PCacdtLFZmX4sL+qKeo4wsyhlD1EvlZF6nV1FSi3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@node-rs/argon2-win32-arm64-msvc@2.1.0':
resolution: {integrity: sha512-KH8lBdjoZLUiVG1GjJQIQPKhmddJmEbJbL+e3S7BKVq1wKl1mx+ClmhGtMtYp8ZtEIw8/BYIFUIjL3sBuWGEuQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@node-rs/argon2-win32-ia32-msvc@2.1.0':
resolution: {integrity: sha512-zZ7NDHoKTgrjoUehNN7xvP+dA/M9krOp42l0MKns6TnMkwrp5nes9agEZsUy+0t0JoR8OQ8D7EUCNLh5BEn++w==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@node-rs/argon2-win32-x64-msvc@2.1.0':
resolution: {integrity: sha512-JN/yNiX7u8Cw9XXTbRrjnlgzFjCiEQe6x97wJnMIsJ3vty89q0SAg504Otd9qy2ofQS2F5CCWvrOXhm/oyHaAQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@node-rs/argon2@2.1.0':
resolution: {integrity: sha512-VBOWfM2u58/to3DFqTGJ2U5cJKQwmjN2zxzsQNZ5a2o8Z6aUrhvqQh8NdgotIF1Y0tMsBNtzOBDBdfvvkwJDSQ==}
engines: {node: '>= 10'}
'@prisma/adapter-pg@7.10.0': '@prisma/adapter-pg@7.10.0':
resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==}
@@ -654,6 +759,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
jose@6.2.10:
resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
json-schema-traverse@1.0.0: json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -988,6 +1096,61 @@ snapshots:
'@esbuild/win32-x64@0.28.2': '@esbuild/win32-x64@0.28.2':
optional: true optional: true
'@node-rs/argon2-android-arm-eabi@2.1.0':
optional: true
'@node-rs/argon2-android-arm64@2.1.0':
optional: true
'@node-rs/argon2-darwin-arm64@2.1.0':
optional: true
'@node-rs/argon2-darwin-x64@2.1.0':
optional: true
'@node-rs/argon2-freebsd-x64@2.1.0':
optional: true
'@node-rs/argon2-linux-arm-gnueabihf@2.1.0':
optional: true
'@node-rs/argon2-linux-arm64-gnu@2.1.0':
optional: true
'@node-rs/argon2-linux-arm64-musl@2.1.0':
optional: true
'@node-rs/argon2-linux-x64-gnu@2.1.0':
optional: true
'@node-rs/argon2-linux-x64-musl@2.1.0':
optional: true
'@node-rs/argon2-win32-arm64-msvc@2.1.0':
optional: true
'@node-rs/argon2-win32-ia32-msvc@2.1.0':
optional: true
'@node-rs/argon2-win32-x64-msvc@2.1.0':
optional: true
'@node-rs/argon2@2.1.0':
optionalDependencies:
'@node-rs/argon2-android-arm-eabi': 2.1.0
'@node-rs/argon2-android-arm64': 2.1.0
'@node-rs/argon2-darwin-arm64': 2.1.0
'@node-rs/argon2-darwin-x64': 2.1.0
'@node-rs/argon2-freebsd-x64': 2.1.0
'@node-rs/argon2-linux-arm-gnueabihf': 2.1.0
'@node-rs/argon2-linux-arm64-gnu': 2.1.0
'@node-rs/argon2-linux-arm64-musl': 2.1.0
'@node-rs/argon2-linux-x64-gnu': 2.1.0
'@node-rs/argon2-linux-x64-musl': 2.1.0
'@node-rs/argon2-win32-arm64-msvc': 2.1.0
'@node-rs/argon2-win32-ia32-msvc': 2.1.0
'@node-rs/argon2-win32-x64-msvc': 2.1.0
'@prisma/adapter-pg@7.10.0': '@prisma/adapter-pg@7.10.0':
dependencies: dependencies:
'@prisma/driver-adapter-utils': 7.10.0 '@prisma/driver-adapter-utils': 7.10.0
@@ -1475,6 +1638,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
jose@6.2.10: {}
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
lodash@4.17.21: {} lodash@4.17.21: {}