Auth completo (Argon2id + JWT/refresh rotativo), permissões, Companies e OpenAPI
- Auth (apps/api/src/auth/): Argon2id com parâmetros versionados
(ADR-0005), access token JWT de 15min, refresh token opaco de 256 bits
com rotação e detecção de replay (reuso de token já rotacionado revoga
toda a cadeia de sessões do usuário). Endpoints /api/v1/auth/
{login,refresh,logout,me}.
- Permissões resource+action+scope (ADR-0004) via PermissionGuard,
resolvidas do banco a cada request (nunca cacheadas no JWT) — mudança
de papel tem efeito imediato. super_admin com bypass hardcoded,
espelhando a trava já existente no banco.
- Companies (migration + CRUD): leitura aberta a qualquer autenticado,
escrita restrita a companies:manage; validação de CNPJ via
packages/domain-shared (dígito verificador).
- SeedService: cria o primeiro super_admin a partir de
EDEN_SUPERADMIN_EMAIL/PASSWORD só quando `users` está vazia.
- OpenAPI/Swagger em /api/docs; ValidationPipe global com
whitelist+forbidNonWhitelisted (proteção contra mass-assignment).
- packages/database: repositórios (roles/users/sessions/audit/companies)
reutilizáveis por api e worker.
Validado manualmente contra a stack real (não só typecheck): login com
senha errada → 401 genérico; refresh rotaciona e invalida o token
anterior; reuso do token antigo → replay detectado, cadeia revogada;
logout idempotente; audit log com a sequência completa de eventos;
CNPJ inválido rejeitado; mass-assignment rejeitado.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,11 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "echo 'no linter configured yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
50
packages/config/src/env-schema.ts
Normal file
50
packages/config/src/env-schema.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Fail fast on boot if a required secret/config value is missing — same
|
||||
* philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks (eden.md §5.6).
|
||||
* Never fall back to an insecure default for a secret.
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, "DATABASE_URL é obrigatório"),
|
||||
EDEN_API_PORT: z.coerce.number().int().positive().default(8080),
|
||||
|
||||
JWT_SIGNING_SECRET: z.string().min(32, "JWT_SIGNING_SECRET precisa ter ao menos 32 caracteres"),
|
||||
SIGNATURE_OTP_SECRET: z.string().min(32).optional(),
|
||||
EDEN_FIELD_ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.min(32, "EDEN_FIELD_ENCRYPTION_KEY precisa ter ao menos 32 caracteres")
|
||||
.optional(),
|
||||
|
||||
EDEN_SUPERADMIN_EMAIL: z.string().email().optional(),
|
||||
EDEN_SUPERADMIN_PASSWORD: z.string().min(8).optional(),
|
||||
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(465),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_REGION: z.string().default("us-east-1"),
|
||||
S3_FORCE_PATH_STYLE: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true"),
|
||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_BUCKET: z.string().optional(),
|
||||
|
||||
PUBLIC_BASE_URL: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EdenEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): EdenEnv {
|
||||
const result = envSchema.safeParse(source);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
||||
throw new Error(`Configuração de ambiente inválida:\n${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -1,6 +1 @@
|
||||
/**
|
||||
* Shared env-var schema/validation (fail fast on boot if a required var is
|
||||
* missing — same philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks,
|
||||
* eden.md §5.6). Placeholder until apps/api's config module needs it.
|
||||
*/
|
||||
export const CONFIG_PACKAGE_VERSION = "0.1.0";
|
||||
export { envSchema, loadEnv, type EdenEnv } from "./env-schema.js";
|
||||
|
||||
54
packages/database/migrations/1788437478189_companies.cjs
Normal file
54
packages/database/migrations/1788437478189_companies.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Organization bounded context, first slice: companies (legal entities
|
||||
* that issue contracts/documents) — Master Prompt §6.1. Shape ported
|
||||
* faithfully from the legacy (eden.md §1.4), since this is validated,
|
||||
* production-tested data. Warehouses/branches/cost centers land in a
|
||||
* later migration once Inventory/Finance need them — Master Prompt §6.1
|
||||
* explicitly warns against leaving `companies` isolated forever, but
|
||||
* that linkage is real work for those later phases, not invented now.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE companies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
company_name TEXT NOT NULL,
|
||||
trade_name TEXT,
|
||||
cnpj TEXT,
|
||||
state_registration TEXT,
|
||||
municipal_registration TEXT,
|
||||
address_zip TEXT,
|
||||
address_street TEXT,
|
||||
address_number TEXT,
|
||||
address_complement TEXT,
|
||||
address_neighborhood TEXT,
|
||||
address_city TEXT,
|
||||
address_state TEXT,
|
||||
address_country TEXT NOT NULL DEFAULT 'Brasil',
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
website TEXT,
|
||||
legal_rep_name TEXT,
|
||||
legal_rep_cpf TEXT,
|
||||
legal_rep_role TEXT,
|
||||
legal_rep_email TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
COMMENT ON TABLE companies IS
|
||||
'Empresa(s) emissora(s) de contrato/documento — eden.md §1.4. Leitura aberta a qualquer autenticado (não sensível), escrita restrita à permissão companies:manage.';
|
||||
|
||||
CREATE TRIGGER trg_companies_updated_at BEFORE UPDATE ON companies
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_companies_active ON companies(is_active);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS companies;`);
|
||||
};
|
||||
@@ -17,6 +17,7 @@
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/pg": "^8.11.10",
|
||||
"node-pg-migrate": "^7.9.1",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export { query, withTransaction, closePool } from "./pool.js";
|
||||
export * as rolesRepo from "./repositories/roles.js";
|
||||
export * as usersRepo from "./repositories/users.js";
|
||||
export * as sessionsRepo from "./repositories/sessions.js";
|
||||
export * as auditRepo from "./repositories/audit.js";
|
||||
export * as companiesRepo from "./repositories/companies.js";
|
||||
export type * from "./repositories/types.js";
|
||||
export type { Company } from "./repositories/companies.js";
|
||||
|
||||
65
packages/database/src/repositories/audit.ts
Normal file
65
packages/database/src/repositories/audit.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { query } from "../pool.js";
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorType: "user" | "system" | "service";
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one event to the audit_log hash chain. Never call this inside a
|
||||
* larger transaction that might roll back for unrelated reasons without
|
||||
* considering that the chain must stay contiguous — a rolled-back event
|
||||
* never happened, which is fine (nothing was recorded), but a *committed*
|
||||
* event must never be skipped when computing previous_event_hash for the
|
||||
* next one. See .claude/skills/eden-security/references/audit.md.
|
||||
*/
|
||||
export async function appendAuditEvent(input: AuditEventInput): Promise<void> {
|
||||
const { rows } = await query<{ event_hash: string }>(
|
||||
"SELECT event_hash FROM audit_log ORDER BY seq DESC LIMIT 1",
|
||||
);
|
||||
const previousEventHash = rows[0]?.event_hash ?? "";
|
||||
|
||||
const fields = {
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
};
|
||||
const eventHash = createHash("sha256")
|
||||
.update(previousEventHash + canonicalJson(fields))
|
||||
.digest("hex");
|
||||
|
||||
await query(
|
||||
`INSERT INTO audit_log
|
||||
(actor_type, actor_id, action, resource_type, resource_id, metadata, ip_address, previous_event_hash, event_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
input.actorType,
|
||||
input.actorId ?? null,
|
||||
input.action,
|
||||
input.resourceType,
|
||||
input.resourceId ?? null,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
input.ipAddress ?? null,
|
||||
previousEventHash || null,
|
||||
eventHash,
|
||||
],
|
||||
);
|
||||
}
|
||||
163
packages/database/src/repositories/companies.ts
Normal file
163
packages/database/src/repositories/companies.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { query } from "../pool.js";
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
company_name: string;
|
||||
trade_name: string | null;
|
||||
cnpj: string | null;
|
||||
state_registration: string | null;
|
||||
municipal_registration: string | null;
|
||||
address_zip: string | null;
|
||||
address_street: string | null;
|
||||
address_number: string | null;
|
||||
address_complement: string | null;
|
||||
address_neighborhood: string | null;
|
||||
address_city: string | null;
|
||||
address_state: string | null;
|
||||
address_country: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
legal_rep_name: string | null;
|
||||
legal_rep_cpf: string | null;
|
||||
legal_rep_role: string | null;
|
||||
legal_rep_email: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function listCompanies(includeInactive = false): Promise<Company[]> {
|
||||
const { rows } = await query<Company>(
|
||||
includeInactive
|
||||
? "SELECT * FROM companies ORDER BY created_at ASC"
|
||||
: "SELECT * FROM companies WHERE is_active = true ORDER BY created_at ASC",
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getCompanyById(id: string): Promise<Company | null> {
|
||||
const { rows } = await query<Company>("SELECT * FROM companies WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateCompanyInput {
|
||||
companyName: string;
|
||||
tradeName?: string | null;
|
||||
cnpj?: string | null;
|
||||
stateRegistration?: string | null;
|
||||
municipalRegistration?: string | null;
|
||||
addressZip?: string | null;
|
||||
addressStreet?: string | null;
|
||||
addressNumber?: string | null;
|
||||
addressComplement?: string | null;
|
||||
addressNeighborhood?: string | null;
|
||||
addressCity?: string | null;
|
||||
addressState?: string | null;
|
||||
addressCountry?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
legalRepName?: string | null;
|
||||
legalRepCpf?: string | null;
|
||||
legalRepRole?: string | null;
|
||||
legalRepEmail?: string | null;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export async function createCompany(input: CreateCompanyInput): Promise<Company> {
|
||||
const { rows } = await query<Company>(
|
||||
`INSERT INTO companies (
|
||||
company_name, trade_name, cnpj, state_registration, municipal_registration,
|
||||
address_zip, address_street, address_number, address_complement, address_neighborhood,
|
||||
address_city, address_state, address_country, phone, email, website,
|
||||
legal_rep_name, legal_rep_cpf, legal_rep_role, legal_rep_email, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,'Brasil'),$14,$15,$16,$17,$18,$19,$20,$21)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.companyName,
|
||||
input.tradeName ?? null,
|
||||
input.cnpj ?? null,
|
||||
input.stateRegistration ?? null,
|
||||
input.municipalRegistration ?? null,
|
||||
input.addressZip ?? null,
|
||||
input.addressStreet ?? null,
|
||||
input.addressNumber ?? null,
|
||||
input.addressComplement ?? null,
|
||||
input.addressNeighborhood ?? null,
|
||||
input.addressCity ?? null,
|
||||
input.addressState ?? null,
|
||||
input.addressCountry ?? null,
|
||||
input.phone ?? null,
|
||||
input.email ?? null,
|
||||
input.website ?? null,
|
||||
input.legalRepName ?? null,
|
||||
input.legalRepCpf ?? null,
|
||||
input.legalRepRole ?? null,
|
||||
input.legalRepEmail ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const company = rows[0];
|
||||
if (!company) throw new Error("failed to create company");
|
||||
return company;
|
||||
}
|
||||
|
||||
export type UpdateCompanyInput = Partial<Omit<CreateCompanyInput, "createdBy">> & {
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export async function updateCompany(id: string, input: UpdateCompanyInput): Promise<Company | null> {
|
||||
const { rows } = await query<Company>(
|
||||
`UPDATE companies SET
|
||||
company_name = COALESCE($2, company_name),
|
||||
trade_name = COALESCE($3, trade_name),
|
||||
cnpj = COALESCE($4, cnpj),
|
||||
state_registration = COALESCE($5, state_registration),
|
||||
municipal_registration = COALESCE($6, municipal_registration),
|
||||
address_zip = COALESCE($7, address_zip),
|
||||
address_street = COALESCE($8, address_street),
|
||||
address_number = COALESCE($9, address_number),
|
||||
address_complement = COALESCE($10, address_complement),
|
||||
address_neighborhood = COALESCE($11, address_neighborhood),
|
||||
address_city = COALESCE($12, address_city),
|
||||
address_state = COALESCE($13, address_state),
|
||||
address_country = COALESCE($14, address_country),
|
||||
phone = COALESCE($15, phone),
|
||||
email = COALESCE($16, email),
|
||||
website = COALESCE($17, website),
|
||||
legal_rep_name = COALESCE($18, legal_rep_name),
|
||||
legal_rep_cpf = COALESCE($19, legal_rep_cpf),
|
||||
legal_rep_role = COALESCE($20, legal_rep_role),
|
||||
legal_rep_email = COALESCE($21, legal_rep_email),
|
||||
is_active = COALESCE($22, is_active)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.companyName ?? null,
|
||||
input.tradeName ?? null,
|
||||
input.cnpj ?? null,
|
||||
input.stateRegistration ?? null,
|
||||
input.municipalRegistration ?? null,
|
||||
input.addressZip ?? null,
|
||||
input.addressStreet ?? null,
|
||||
input.addressNumber ?? null,
|
||||
input.addressComplement ?? null,
|
||||
input.addressNeighborhood ?? null,
|
||||
input.addressCity ?? null,
|
||||
input.addressState ?? null,
|
||||
input.addressCountry ?? null,
|
||||
input.phone ?? null,
|
||||
input.email ?? null,
|
||||
input.website ?? null,
|
||||
input.legalRepName ?? null,
|
||||
input.legalRepCpf ?? null,
|
||||
input.legalRepRole ?? null,
|
||||
input.legalRepEmail ?? null,
|
||||
input.isActive ?? null,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
20
packages/database/src/repositories/roles.ts
Normal file
20
packages/database/src/repositories/roles.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { query } from "../pool.js";
|
||||
import type { Role, RolePermission } from "./types.js";
|
||||
|
||||
export async function getRoleByKey(key: string): Promise<Role | null> {
|
||||
const { rows } = await query<Role>("SELECT * FROM roles WHERE key = $1", [key]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listRoles(): Promise<Role[]> {
|
||||
const { rows } = await query<Role>("SELECT * FROM roles ORDER BY weight ASC");
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getRolePermissions(roleKey: string): Promise<RolePermission[]> {
|
||||
const { rows } = await query<RolePermission>(
|
||||
"SELECT * FROM role_permissions WHERE role_key = $1",
|
||||
[roleKey],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
84
packages/database/src/repositories/sessions.ts
Normal file
84
packages/database/src/repositories/sessions.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { query } from "../pool.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
applicationKey: "core" | "reseller" | "subscriber";
|
||||
refreshTokenHash: string;
|
||||
expiresAt: Date;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
replacesSessionId?: string | null;
|
||||
}
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<Session> {
|
||||
const { rows } = await query<Session>(
|
||||
`INSERT INTO sessions (user_id, application_key, refresh_token_hash, expires_at, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.userId,
|
||||
input.applicationKey,
|
||||
input.refreshTokenHash,
|
||||
input.expiresAt,
|
||||
input.ipAddress ?? null,
|
||||
input.userAgent ?? null,
|
||||
],
|
||||
);
|
||||
const session = rows[0];
|
||||
if (!session) throw new Error("failed to create session");
|
||||
|
||||
if (input.replacesSessionId) {
|
||||
await query("UPDATE sessions SET replaced_by_session_id = $1, revoked_at = now() WHERE id = $2", [
|
||||
session.id,
|
||||
input.replacesSessionId,
|
||||
]);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function findActiveSessionByRefreshTokenHash(hash: string): Promise<Session | null> {
|
||||
const { rows } = await query<Session>(
|
||||
`SELECT * FROM sessions
|
||||
WHERE refresh_token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`,
|
||||
[hash],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a session by refresh token hash regardless of revoked/expired state —
|
||||
* used for replay detection: a rotated (already-revoked) token being
|
||||
* reused is a strong signal of a stolen refresh token (ADR-0003).
|
||||
*/
|
||||
export async function findSessionByRefreshTokenHashAnyState(hash: string): Promise<Session | null> {
|
||||
const { rows } = await query<Session>("SELECT * FROM sessions WHERE refresh_token_hash = $1", [
|
||||
hash,
|
||||
]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function touchSession(id: string): Promise<void> {
|
||||
await query("UPDATE sessions SET last_used_at = now() WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
export async function revokeSession(id: string): Promise<void> {
|
||||
await query("UPDATE sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", [id]);
|
||||
}
|
||||
|
||||
/** Revokes every active session of a user — "encerrar todas as sessões" (Master Prompt §5.3). */
|
||||
export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
await query("UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function listActiveSessionsForUser(userId: string): Promise<Session[]> {
|
||||
const { rows } = await query<Session>(
|
||||
`SELECT * FROM sessions WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()
|
||||
ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
53
packages/database/src/repositories/types.ts
Normal file
53
packages/database/src/repositories/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface Role {
|
||||
key: string;
|
||||
label: string;
|
||||
is_system: boolean;
|
||||
weight: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface RolePermission {
|
||||
id: string;
|
||||
role_key: string;
|
||||
resource: string;
|
||||
action: "view" | "create" | "edit" | "delete" | "approve" | "export" | "manage";
|
||||
scope: "own" | "team" | "reseller" | "legal_entity" | "all";
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
full_name: string | null;
|
||||
email: string;
|
||||
password_hash: string | null;
|
||||
password_algo_version: number;
|
||||
role_key: string;
|
||||
is_active: boolean;
|
||||
reset_token_hash: string | null;
|
||||
reset_token_expires_at: Date | null;
|
||||
mfa_enabled: boolean;
|
||||
mfa_secret_encrypted: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
application_key: "core" | "reseller" | "subscriber";
|
||||
refresh_token_hash: string;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
replaced_by_session_id: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
key: "core" | "reseller" | "subscriber";
|
||||
label: string;
|
||||
}
|
||||
80
packages/database/src/repositories/users.ts
Normal file
80
packages/database/src/repositories/users.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { query } from "../pool.js";
|
||||
import type { User } from "./types.js";
|
||||
|
||||
export async function countUsers(): Promise<number> {
|
||||
const { rows } = await query<{ count: string }>("SELECT count(*) FROM users");
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
export async function findUserByEmail(email: string): Promise<User | null> {
|
||||
const { rows } = await query<User>(
|
||||
"SELECT * FROM users WHERE lower(email) = lower($1) AND is_active = true",
|
||||
[email],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findUserById(id: string): Promise<User | null> {
|
||||
const { rows } = await query<User>("SELECT * FROM users WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
passwordAlgoVersion: number;
|
||||
roleKey: string;
|
||||
fullName?: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export async function createUser(input: CreateUserInput): Promise<User> {
|
||||
const { rows } = await query<User>(
|
||||
`INSERT INTO users (email, password_hash, password_algo_version, role_key, full_name, created_by)
|
||||
VALUES (lower($1), $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.email,
|
||||
input.passwordHash,
|
||||
input.passwordAlgoVersion,
|
||||
input.roleKey,
|
||||
input.fullName ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) throw new Error("failed to create user");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateUserPassword(
|
||||
userId: string,
|
||||
passwordHash: string,
|
||||
passwordAlgoVersion: number,
|
||||
): Promise<void> {
|
||||
await query("UPDATE users SET password_hash = $1, password_algo_version = $2 WHERE id = $3", [
|
||||
passwordHash,
|
||||
passwordAlgoVersion,
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function grantUserApplication(
|
||||
userId: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO user_applications (user_id, application_key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, application_key) DO NOTHING`,
|
||||
[userId, applicationKey],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserApplications(userId: string): Promise<string[]> {
|
||||
const { rows } = await query<{ application_key: string }>(
|
||||
"SELECT application_key FROM user_applications WHERE user_id = $1",
|
||||
[userId],
|
||||
);
|
||||
return rows.map((r) => r.application_key);
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
/**
|
||||
* Domain types/constants shared across bounded contexts (e.g. the
|
||||
* resource/action/scope permission vocabulary from
|
||||
* docs/adr/0004-permission-model.md). Placeholder until a second
|
||||
* bounded context needs to share a type with the first.
|
||||
*/
|
||||
export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0";
|
||||
export { onlyDigits, isValidCPF, isValidCNPJ } from "./validators/br-documents.js";
|
||||
|
||||
49
packages/domain-shared/src/validators/br-documents.ts
Normal file
49
packages/domain-shared/src/validators/br-documents.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* CPF/CNPJ format validation (digit-check only — never confirms a document
|
||||
* actually exists or belongs to whoever claims it). Ported from the
|
||||
* standard Brazilian algorithm, matching the legacy's isValidCPF/
|
||||
* isValidCNPJ (eden.md §1 companies, §3 client registration).
|
||||
*/
|
||||
export function onlyDigits(value: string): string {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
export function isValidCPF(value: string): boolean {
|
||||
const cpf = onlyDigits(value);
|
||||
if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false;
|
||||
|
||||
const digits = cpf.split("").map(Number);
|
||||
const checkDigit = (length: number): number => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
sum += digits[i]! * (length + 1 - i);
|
||||
}
|
||||
const remainder = (sum * 10) % 11;
|
||||
return remainder === 10 ? 0 : remainder;
|
||||
};
|
||||
|
||||
return checkDigit(9) === digits[9] && checkDigit(10) === digits[10];
|
||||
}
|
||||
|
||||
export function isValidCNPJ(value: string): boolean {
|
||||
const cnpj = onlyDigits(value);
|
||||
if (cnpj.length !== 14 || /^(\d)\1{13}$/.test(cnpj)) return false;
|
||||
|
||||
const digits = cnpj.split("").map(Number);
|
||||
// Standard CNPJ weight sequences — the second is the first with a
|
||||
// leading 6, not a generic slice (easy to get subtly wrong otherwise).
|
||||
const WEIGHTS_12 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
const WEIGHTS_13 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
const checkDigit = (length: number, weights: number[]): number => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < length; i++) {
|
||||
sum += digits[i]! * weights[i]!;
|
||||
}
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? 0 : 11 - remainder;
|
||||
};
|
||||
|
||||
return (
|
||||
checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user