Inicio do Projeto Piloto
ci / verify (push) Has been cancelled

This commit is contained in:
camilafagundes committed 2026-10-02 23:59:36 -03:00
1 parent 6e23f1533b
commit 419325db12
52 files changed
+1329 -110

No files matched your search

+16 -15
View File
@@ -1,21 +1,24 @@
import "server-only";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import type { RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
import { getDb } from "@/infra/db/client";
import { devAuthEnabled, verifyDevSession } from "./dev-session";
export type AuthState =
| { kind: "anonymous" }
| { kind: "session_expired" }
| { kind: "no_workspace" }
| { kind: "not_member" }
| { kind: "ok"; ctx: RequestContext };
| { kind: "ok"; ctx: AuthenticatedContext };
export async function resolveAuth(): Promise<AuthState> {
const jar = await cookies();
let userId: string | null = null;
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
const devToken = jar.get("adapter_dev_session")?.value;
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
if (devToken && devAuthEnabled()) {
const dev = verifyDevSession(devToken);
if (dev) {
@@ -37,7 +40,7 @@ export async function resolveAuth(): Promise<AuthState> {
const { data } = await supabase.auth.getUser();
userId = data.user?.id ?? null;
}
if (!userId) return { kind: "anonymous" };
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
if (!workspaceId) return { kind: "no_workspace" };
const db = await getDb();
const rows = await db.withUser(userId, (query) =>
@@ -63,17 +66,15 @@ export async function resolveAuth(): Promise<AuthState> {
);
const row = rows[0];
if (!row) return { kind: "not_member" };
return {
kind: "ok",
ctx: {
userId,
displayName: row.display_name,
isSuperadmin: row.is_superadmin,
workspaceId,
workspaceName: row.workspace_name,
role: row.role,
moduleEnabled: row.module_enabled,
allowNonAdminModelUpload: row.allow_upload,
},
const ctx = {
userId,
displayName: row.display_name,
isSuperadmin: row.is_superadmin,
workspaceId,
workspaceName: row.workspace_name,
role: row.role,
moduleEnabled: row.module_enabled,
allowNonAdminModelUpload: row.allow_upload,
};
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Pontos de integração com a identidade Elo Pro.
* Os nomes abaixo são o contrato que o Adaptador lê hoje no shim local.
* Eles NÃO estão confirmados contra o banco real da Elo Pro.
* Quando o repositório real estiver disponível, este arquivo é o único
* lugar que deve mudar para apontar às tabelas existentes. Não criar users,
* senha, cadastro ou papel paralelo.
*/
export const eloProIdentityContract = {
authUsers: "auth.users",
profile: { table: "public.profiles", status: "unconfirmed_against_real_elo_pro" },
workspace: { table: "public.workspaces", status: "unconfirmed_against_real_elo_pro" },
membership: { table: "public.workspace_members", status: "unconfirmed_against_real_elo_pro" },
moduleFlag: { table: "public.workspace_module_flags", key: "spreadsheet_adapter", status: "adapter_specific" },
workspaceCookie: "elo_pro_workspace_id",
} as const;
+38
View File
@@ -0,0 +1,38 @@
export function allowedReturnHosts(env: Record<string, string | undefined> = process.env): string[] {
const hosts = (env.AUTH_ALLOWED_RETURN_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
if (env.ELO_PRO_APP_URL) {
try {
hosts.push(new URL(env.ELO_PRO_APP_URL).host);
} catch {
/* host inválido não entra na lista */
}
}
return [...new Set(hosts)];
}
/** Aceita somente caminho relativo interno. Host externo é recusado. */
export function safeReturnPath(input: string | null | undefined): string {
if (!input) return "/adaptador";
if (!input.startsWith("/") || input.startsWith("//") || input.includes("\\") || input.includes("://")) {
return "/adaptador";
}
const path = input.slice(0, 300);
return path.startsWith("/login") ? "/adaptador" : path;
}
export function buildCentralLoginUrl(loginUrl: string, returnPath: string, appUrl?: string): string {
const target = new URL(loginUrl);
const path = safeReturnPath(returnPath);
if (!appUrl) {
target.searchParams.set("returnTo", path);
return target.toString();
}
const app = new URL(appUrl);
const absolute = new URL(path, app);
if (absolute.origin !== app.origin) return target.toString();
target.searchParams.set("returnTo", absolute.toString());
return target.toString();
}
+72 -30
View File
@@ -1,4 +1,5 @@
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
import { shouldRequeue } from "@/domain/spreadsheet-adapter/failure-policy";
import { neutralizeRow, neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
import { buildOutputFilename } from "@/domain/spreadsheet-adapter/filenames";
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
@@ -22,6 +23,12 @@ function assertUse(ctx: RequestContext): void {
}
}
function assertOwnedTempKey(workspaceId: string, key: string): void {
if (!key.startsWith(`workspaces/${workspaceId}/tmp/`) || !key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
}
}
async function audit(
query: Query,
ctx: RequestContext,
@@ -37,15 +44,18 @@ async function audit(
);
}
function assertTempKey(key: string): void {
if (!key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
}
function processTimeoutMs(): number {
const raw = process.env.SPREADSHEET_JOB_TIMEOUT_MS;
if (!raw) return 120_000;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : 120_000;
}
export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<void> {
const userLock = lockKey(ctx.userId);
const written: string[] = [];
const startedAt = Date.now();
const timeoutMs = processTimeoutMs();
try {
const owns = await deps.coordinator.ownsLock(userLock, jobId);
if (!owns) {
@@ -120,8 +130,14 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
).map(neutralizeRow);
const headers = columns.map((column) => neutralizeSpreadsheetFormula(column.name));
let done = 0;
if (Date.now() - startedAt >= timeoutMs) {
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
}
for (const _row of transformed) {
done += 1;
if (Date.now() - startedAt >= timeoutMs) {
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
}
if (done % 5000 === 0) {
await progress({
phase: "processing",
@@ -136,8 +152,8 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
await progress({ phase: "generating", label: "Gerando arquivo", percent: null, sourceIndex, sourceCount: total, sourceId });
const csvKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.csv`;
const xlsxKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.xlsx`;
assertTempKey(csvKey);
assertTempKey(xlsxKey);
assertOwnedTempKey(ctx.workspaceId, csvKey);
assertOwnedTempKey(ctx.workspaceId, xlsxKey);
const csv = buildCsv(headers, transformed);
const xlsx = await buildXlsx(headers, transformed);
await deps.storage.put(csvKey, csv);
@@ -180,28 +196,38 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
} catch (error) {
for (const key of written) await deps.storage.delete(key).catch(() => undefined);
const code = isAdapterError(error) ? error.code : "PROCESS_FAILED";
const failed: JobProgress = {
phase: "failed",
label: "Falhou",
percent: null,
sourceIndex: 1,
sourceCount: 1,
sourceId,
};
const httpStatus = isAdapterError(error) ? error.status : 500;
let requeue = false;
await deps.db
.withUser(ctx.userId, async (query) => {
await query.query(`update public.spreadsheet_job_sources set status = 'failed', error_code = $2 where id = $1 and status <> 'completed'`, [
const current = await query.query<{ progress: unknown }>(`select progress from public.spreadsheet_jobs where id = $1`, [jobId]);
const prev = asJson<JobProgress | null>(current[0]?.progress, null);
const attempt = (prev?.attempt ?? 0) + 1;
requeue = shouldRequeue(code, httpStatus, attempt);
const next: JobProgress = {
phase: requeue ? "validating" : "failed",
label: requeue ? "Nova tentativa" : "Falhou",
percent: null,
sourceIndex: 1,
sourceCount: 1,
sourceId,
code,
]);
attempt,
};
await query.query(
`update public.spreadsheet_jobs set status = 'failed', error_code = $2, progress = $3::jsonb, finished_at = now(), updated_at = now() where id = $1`,
[jobId, code, JSON.stringify(failed)],
`update public.spreadsheet_job_sources set status = $2, error_code = $3 where id = $1 and status <> 'completed'`,
[sourceId, requeue ? "mapped" : "failed", code],
);
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId });
await query.query(
`update public.spreadsheet_jobs
set status = $2, error_code = $3, progress = $4::jsonb,
finished_at = case when $2 = 'failed' then now() else finished_at end, updated_at = now()
where id = $1`,
[jobId, requeue ? "queued" : "failed", code, JSON.stringify(next)],
);
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId, attempt });
})
.catch(() => undefined);
await deps.coordinator.setProgress(jobId, failed).catch(() => undefined);
if (requeue) await deps.coordinator.enqueue(jobId).catch(() => undefined);
if (!isAdapterError(error) || error.status >= 500) {
deps.capture?.(error, { module: "spreadsheet-adapter", job_id: jobId, workspace_id: ctx.workspaceId, error_code: code });
}
@@ -220,6 +246,16 @@ const CONTENT_TYPE: Record<OutputFormat, string> = {
export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, format: OutputFormat) {
assertUse(ctx);
const flags = await deps.db.withUser(ctx.userId, (query) =>
query.query<{ enabled: boolean }>(
`select enabled from public.workspace_module_flags where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
[ctx.workspaceId],
),
);
const flag = flags[0]?.enabled as unknown;
if (flag !== true && flag !== "t" && flag !== "true") {
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
}
const file = await deps.db.withUser(ctx.userId, async (query) => {
const rows = await query.query<{
model_name: string;
@@ -242,7 +278,7 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
const filename = buildOutputFilename(file.model_name, format, deps.now?.() ?? new Date());
if (format === "pdf") {
if (!file.output_csv_key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
assertTempKey(file.output_csv_key);
assertOwnedTempKey(ctx.workspaceId, file.output_csv_key);
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
const grid = parseCsvText(csv);
const headers = grid[0] ?? [];
@@ -251,29 +287,35 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
}
const key = format === "csv" ? file.output_csv_key : file.output_xlsx_key;
if (!key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
assertTempKey(key);
assertOwnedTempKey(ctx.workspaceId, key);
return { filename, contentType: CONTENT_TYPE[format], body: await deps.storage.get(key) };
}
export async function cleanupExpired(deps: AppDeps): Promise<number> {
const rows = await deps.db.withService((query) =>
query.query<{ id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
`select id, temp_storage_key, output_csv_key, output_xlsx_key
from public.spreadsheet_job_sources
where expires_at < $1::timestamptz and status <> 'expired'`,
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
`select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
[(deps.now ?? (() => new Date()))().toISOString()],
),
);
let removed = 0;
for (const row of rows) {
const prefix = `workspaces/${row.workspace_id}/tmp/`;
for (const key of [row.temp_storage_key, row.output_csv_key, row.output_xlsx_key]) {
if (!key || !key.includes("/tmp/") || key.includes("/models/")) continue;
await deps.storage.delete(key).catch(() => undefined);
if (!key || !key.startsWith(prefix) || key.includes("..") || key.includes("/models/")) continue;
await deps.storage.delete(key).catch((error) => {
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
});
}
await deps.db.withService((query) =>
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
);
removed += 1;
}
return rows.length;
return removed;
}
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
+39 -17
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { jobRunsInline } from "@/config/env";
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
import { MAX_ACTIVE_MODELS, MAX_SOURCES_PER_JOB } from "@/domain/spreadsheet-adapter/limits";
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
@@ -26,11 +27,22 @@ function ttl(deps: AppDeps): number {
return deps.ttlSeconds ?? tempTtlSeconds();
}
function assertUse(ctx: RequestContext): void {
async function assertUse(deps: AppDeps, ctx: RequestContext): Promise<void> {
if (!ctx.moduleEnabled || !canUseSpreadsheetAdapter(ctx)) {
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
}
if (!canOperateSpreadsheet(ctx)) throw new AdapterError("FORBIDDEN", "Sem permissão para operar o Adaptador.", 403);
const flags = await deps.db.withUser(ctx.userId, (query) =>
query.query<{ enabled: boolean }>(
`select enabled from public.workspace_module_flags
where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
[ctx.workspaceId],
),
);
const flag = flags[0]?.enabled as unknown;
if (flag !== true && flag !== "t" && flag !== "true") {
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
}
}
async function audit(
@@ -60,12 +72,12 @@ export async function rejectWorkspaceForgery(deps: AppDeps, ctx: RequestContext,
}
export async function listModels(deps: AppDeps, ctx: RequestContext): Promise<ModelDto[]> {
assertUse(ctx);
await assertUse(deps, ctx);
return deps.db.withUser(ctx.userId, (query) => readModels(query, ctx.workspaceId));
}
export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{ allowNonAdminModelUpload: boolean }> {
assertUse(ctx);
await assertUse(deps, ctx);
const rows = await deps.db.withUser(ctx.userId, (query) =>
query.query<{ allow_non_admin_model_upload: boolean }>(
`select allow_non_admin_model_upload from public.spreadsheet_adapter_settings where workspace_id = $1`,
@@ -76,7 +88,7 @@ export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{
}
export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNonAdminModelUpload: boolean): Promise<void> {
assertUse(ctx);
await assertUse(deps, ctx);
if (!canManageSpreadsheetModels(ctx)) {
throw new AdapterError("FORBIDDEN", "Somente owner ou admin alteram a configuração do módulo.", 403);
}
@@ -95,7 +107,7 @@ export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNo
}
export async function createModel(deps: AppDeps, ctx: RequestContext, input: ModelCreateInput): Promise<ModelDto> {
assertUse(ctx);
await assertUse(deps, ctx);
if (!canUploadSpreadsheetModel(ctx)) {
throw new AdapterError("FORBIDDEN", "Seu perfil não pode cadastrar modelos neste workspace.", 403);
}
@@ -158,7 +170,7 @@ export async function createModel(deps: AppDeps, ctx: RequestContext, input: Mod
}
export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: string): Promise<void> {
assertUse(ctx);
await assertUse(deps, ctx);
if (!canManageSpreadsheetModels(ctx)) {
throw new AdapterError("FORBIDDEN", "Somente owner ou admin podem excluir modelos.", 403);
}
@@ -180,7 +192,7 @@ export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: s
}
export async function createJob(deps: AppDeps, ctx: RequestContext, modelId: string, idempotencyKey?: string | null): Promise<JobDto> {
assertUse(ctx);
await assertUse(deps, ctx);
const jobId = await deps.db.withUser(ctx.userId, async (query) => {
if (idempotencyKey) {
const existing = await query.query<{ id: string }>(
@@ -219,7 +231,7 @@ async function ensureMutableJob(query: Query, ctx: RequestContext, jobId: string
}
export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: string, file: UploadFile, sheet?: string | null, headerRow = 1) {
assertUse(ctx);
await assertUse(deps, ctx);
const filename = sanitizeDisplayName(file.filename);
let parsed: Awaited<ReturnType<typeof readSpreadsheet>> | null = null;
let sheetRequired: string[] | null = null;
@@ -274,6 +286,10 @@ export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: strin
`update public.spreadsheet_jobs set source_count = source_count + 1, updated_at = now() where id = $1`,
[jobId],
);
await audit(query, ctx, "source.uploaded", "job_source", sourceId, status === "invalid" ? "error" : "success", {
size_bytes: file.bytes.length,
row_count: parsed?.grid.rows.length ?? 0,
});
});
} catch (error) {
await deps.storage.delete(storageKey);
@@ -296,7 +312,7 @@ export async function updateSource(
sourceId: string,
input: { sheet?: string | null; headerRow?: number },
) {
assertUse(ctx);
await assertUse(deps, ctx);
await deps.db.withUser(ctx.userId, async (query) => {
await ensureMutableJob(query, ctx, jobId);
const rows = await query.query<{ temp_storage_key: string; source_name: string; header_row: number; selected_sheet: string | null }>(
@@ -337,7 +353,7 @@ export async function updateSource(
}
export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<JobDto> {
assertUse(ctx);
await assertUse(deps, ctx);
const key = await deps.db.withUser(ctx.userId, async (query) => {
await ensureMutableJob(query, ctx, jobId);
const rows = await query.query<{ temp_storage_key: string; status: string }>(
@@ -355,7 +371,7 @@ export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: st
}
export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, mappings: MappingInput[]) {
assertUse(ctx);
await assertUse(deps, ctx);
await deps.db.withUser(ctx.userId, async (query) => {
await ensureMutableJob(query, ctx, jobId);
const job = await readJob(query, deps.coordinator, ctx, jobId);
@@ -387,12 +403,16 @@ export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: st
[jobId],
);
}
await audit(query, ctx, "mapping.updated", "job_source", sourceId, "success", {
mapping_count: mappings.length,
complete: result.ok,
});
}).catch(mapDbError);
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
}
export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string) {
assertUse(ctx);
await assertUse(deps, ctx);
return deps.db.withUser(ctx.userId, async (query) => {
const job = await readJob(query, deps.coordinator, ctx, jobId);
const source = job.sources.find((item) => item.id === sourceId);
@@ -427,13 +447,14 @@ export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: s
}
function inlineMode(): boolean {
if (process.env.ADAPTER_JOB_MODE === "worker") return false;
if (process.env.ADAPTER_JOB_MODE === "inline") return true;
return process.env.NODE_ENV !== "production";
if (process.env.NODE_ENV === "production" && process.env.ADAPTER_JOB_MODE === "inline") {
throw new AdapterError("INVALID_SETTING", "Processamento inline não é permitido em produção.", 500);
}
return jobRunsInline();
}
export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, idempotencyKey: string): Promise<JobDto> {
assertUse(ctx);
await assertUse(deps, ctx);
if (!idempotencyKey || idempotencyKey.length > 120) {
throw new AdapterError("IDEMPOTENCY_REQUIRED", "Informe uma chave de idempotência para processar.", 400);
}
@@ -501,12 +522,13 @@ export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: st
);
await audit(query, ctx, "job.started", "job", jobId, "success", { source_id: sourceId });
});
if (!inlineMode()) await deps.coordinator.enqueue(jobId).catch(() => undefined);
if (inlineMode()) await runProcess(deps, ctx, jobId, sourceId);
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
}
export async function getJob(deps: AppDeps, ctx: RequestContext, jobId: string): Promise<JobDto> {
assertUse(ctx);
await assertUse(deps, ctx);
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
}