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:
1 parent
b972589949
commit
f5560cc4ac
15 files changed
+586
-55
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import "server-only";
|
||||
import type { Db } from "@/infra/db/types";
|
||||
import type { Role } from "@/domain/spreadsheet-adapter/types";
|
||||
|
||||
export type Membership = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
role: Role;
|
||||
moduleEnabled: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "resolved"; workspaceId: string }
|
||||
| { kind: "choice"; memberships: Membership[] };
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isWorkspaceId(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associações reais do usuário, lidas com o papel `app_user` sob RLS.
|
||||
* Esta é a única fonte de autorização de workspace.
|
||||
*/
|
||||
export async function listMemberships(db: Db, userId: string): Promise<Membership[]> {
|
||||
const rows = await db.withUser(userId, (query) =>
|
||||
query.query<{
|
||||
workspace_id: string;
|
||||
workspace_name: string;
|
||||
role: Role;
|
||||
module_enabled: boolean;
|
||||
}>(
|
||||
`select w.id as workspace_id, w.name as workspace_name, m.role,
|
||||
coalesce(f.enabled, false) as module_enabled
|
||||
from public.workspace_members m
|
||||
join public.workspaces w on w.id = m.workspace_id
|
||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||
where m.user_id = $1
|
||||
order by w.name asc`,
|
||||
[userId],
|
||||
),
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
workspaceId: row.workspace_id,
|
||||
workspaceName: row.workspace_name,
|
||||
role: row.role,
|
||||
moduleEnabled: row.module_enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve o workspace no servidor. O cookie da plataforma é apenas uma dica de
|
||||
* preferência: se não corresponder a uma associação real ele é ignorado, nunca
|
||||
* aceito como prova. Com uma única associação a escolha é automática; com mais
|
||||
* de uma e sem dica válida, quem escolhe é a pessoa.
|
||||
*/
|
||||
export function resolveWorkspace(memberships: Membership[], hint: string | null | undefined): WorkspaceResolution {
|
||||
if (memberships.length === 0) return { kind: "none" };
|
||||
if (isWorkspaceId(hint)) {
|
||||
const match = memberships.find((item) => item.workspaceId.toLowerCase() === hint.toLowerCase());
|
||||
if (match) return { kind: "resolved", workspaceId: match.workspaceId };
|
||||
}
|
||||
if (memberships.length === 1) return { kind: "resolved", workspaceId: memberships[0].workspaceId };
|
||||
return { kind: "choice", memberships };
|
||||
}
|
||||
|
||||
/** Confirma que o usuário é membro antes de gravar a preferência no cookie. */
|
||||
export async function assertMembership(db: Db, userId: string, workspaceId: string): Promise<Membership> {
|
||||
if (!isWorkspaceId(workspaceId)) throw new Error("workspace inválido");
|
||||
const memberships = await listMemberships(db, userId);
|
||||
const match = memberships.find((item) => item.workspaceId.toLowerCase() === workspaceId.toLowerCase());
|
||||
if (!match) throw new Error("associação não encontrada");
|
||||
return match;
|
||||
}
|
||||
Reference in new issue
Block a user