Autentica homologação com Supabase real e resolve workspace no servidor

Duas lacunas impediam qualquer uso de homologação. Primeira: sem SSO do Elo
Pro e sem o login local, não havia como entrar. Segunda, mais grave: nada no
repositório gravava o cookie `elo_pro_workspace_id`, que era lido como origem
do workspace ativo — mesmo com sessão válida a resposta era sempre "sem
workspace".

- `identity-provider.ts` define o contrato de identidade. Trocar o Supabase de
  homologação pelo SSO do Elo Pro passa a ser acrescentar uma implementação;
  nenhum outro arquivo conhece o mecanismo.
- `/api/auth/staging/session` autentica com `signInWithPassword` no projeto
  Supabase de homologação. Há senha, emitida e validada pelo Supabase, e o
  Adaptador continua sem tabela de usuário, senha ou sessão própria.
- O login local sem senha existe apenas em `ADAPTER_ENV=development`, e não
  por configuração: fora de development o provedor nem entra na lista.
- `workspace.ts` resolve o workspace por associação real lida sob RLS. O
  cookie passa a ser preferência, não prova: aceitá-lo como autorização
  transformava um valor editável pelo cliente em credencial.
- `/api/auth/workspace` grava a preferência só depois de confirmar a
  associação, e `/selecionar-workspace` existe para quem participa de mais de
  um workspace.
- Rate limiting por usuário e bucket nas rotas do módulo, com Retry-After.
  Upload e processamento custam muito mais que leitura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:38:45 -03:00
1 parent b972589949
commit f5560cc4ac
15 files changed
+586 -55

No files matched your search

+36 -32
View File
@@ -1,49 +1,45 @@
import "server-only";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
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";
import { activeProviders, WORKSPACE_COOKIE, type Identity } from "./identity-provider";
import { listMemberships, resolveWorkspace, type Membership } from "./workspace";
export type AuthState =
| { kind: "anonymous" }
| { kind: "session_expired" }
| { kind: "no_workspace" }
| { kind: "not_member" }
| { kind: "workspace_choice"; memberships: Membership[] }
| { kind: "ok"; ctx: AuthenticatedContext };
export async function resolveAuth(): Promise<AuthState> {
export { WORKSPACE_COOKIE };
async function resolveIdentity(): Promise<{ identity: Identity | null; hadHint: boolean }> {
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) {
userId = dev.userId;
workspaceId = dev.workspaceId;
}
const providers = activeProviders();
let hadHint = false;
for (const provider of providers) {
if (provider.hadSessionHint(jar)) hadHint = true;
const identity = await provider.resolve(jar);
if (identity) return { identity, hadHint };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!userId && supabaseUrl && supabaseKey) {
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) jar.set(item.name, item.value, item.options);
},
},
});
const { data } = await supabase.auth.getUser();
userId = data.user?.id ?? null;
}
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
if (!workspaceId) return { kind: "no_workspace" };
return { identity: null, hadHint };
}
export async function resolveAuth(): Promise<AuthState> {
const { identity, hadHint } = await resolveIdentity();
if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
const db = await getDb();
const rows = await db.withUser(userId, (query) =>
const memberships = await listMemberships(db, identity.userId);
const resolution = resolveWorkspace(memberships, identity.workspaceHint);
if (resolution.kind === "none") return { kind: "no_workspace" };
if (resolution.kind === "choice") return { kind: "workspace_choice", memberships: resolution.memberships };
const workspaceId = resolution.workspaceId;
const rows = await db.withUser(identity.userId, (query) =>
query.query<{
display_name: string;
is_superadmin: boolean;
@@ -61,13 +57,13 @@ export async function resolveAuth(): Promise<AuthState> {
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[userId, workspaceId],
[identity.userId, workspaceId],
),
);
const row = rows[0];
if (!row) return { kind: "not_member" };
const ctx = {
userId,
userId: identity.userId,
displayName: row.display_name,
isSuperadmin: row.is_superadmin,
workspaceId,
@@ -78,3 +74,11 @@ export async function resolveAuth(): Promise<AuthState> {
};
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
}
/** Lista as associações do usuário autenticado, para a tela de escolha. */
export async function currentMemberships(): Promise<{ userId: string; memberships: Membership[] } | null> {
const { identity } = await resolveIdentity();
if (!identity) return null;
const db = await getDb();
return { userId: identity.userId, memberships: await listMemberships(db, identity.userId) };
}