Onde
lib/actions/reset-account.ts:66-123 — a função inteira; o ponto exato é :89 (delete de userSettings) combinado com :99 (provisionamento de DEK nova).
Consumidor que quebra: lib/queries/admin.ts:49-67 → app/(app)/admin/page.tsx:46.
Evidência
resetAccount apaga userSettings — que é onde mora encryptedDek — dentro da transaction da fase 1:
// lib/actions/reset-account.ts:89
await tx.delete(userSettings).where(eq(userSettings.userId, userId))
…e a fase 2 provisiona uma DEK nova:
// :98-99
// Phase 2: Provision new DEK (userSettings was deleted — getDekForUser creates a fresh row)
const dek = await getDekForUser(userId)
A DEK não é derivada de nada — é aleatória (lib/crypto/keys.ts:21-23):
export function generateDek(): Buffer {
return randomBytes(KEY_LEN)
}
O COALESCE de getDekForUser (keys.ts:57) só preserva DEK de uma linha que existe. Como a fase 1 apagou a linha, o INSERT grava a chave nova e a antiga desaparece — não há cópia em lugar nenhum.
O problema é o que sobra. A fase 1 apaga 16 tabelas. Comparando com as 21 pgTable do schema, feedback é a única tabela com coluna cifrada por DEK que sobrevive ao reset:
// lib/db/schema.ts:538-548
export const feedback = pgTable('feedback', {
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
message: text('message').notNull(), // cifrado — lib/actions/feedback.ts:28
...
})
// lib/actions/feedback.ts:28
message: encryptField(data.message, dek),
Depois do reset, essas linhas continuam cifradas com a DEK que foi destruída. Aí entra o contrato que mente:
// lib/queries/admin.ts:49-67 — a assinatura devolve `message: string` (texto claro)
const getDecryptedMessage = async (userId: string, message: string) => {
if (!dekMap.has(userId)) dekMap.set(userId, await getDekForUser(userId))
return decryptField(message, dekMap.get(userId)!) // :54
}
const decrypted = await Promise.all(rows.map(async (row) => ({ ... }))) // :57
decryptField não degrada: decipher.final() (lib/crypto/fields.ts:24) lança quando o auth tag do GCM não confere. Um único throw dentro do Promise.all de :57 rejeita a query inteira, e app/(app)/admin/page.tsx:46 a chama dentro de outro Promise.all — a página cai no app/(app)/error.tsx.
O tipo é verdade sintática e mentira semântica: getAllFeedbacks(): Promise<{ message: string, ... }[]> promete texto claro. O contrato real, não escrito em lugar nenhum, é "a DEK atual do usuário é a mesma que cifrou esta linha" — e resetAccount é exatamente o código que rompe essa invariante, sem que nada no compilador ou no schema o registre.
Verificações feitas (tentativa de falsificar o achado)
1. Rodei o crypto real (node, sem node_modules — fields.ts usa só node:crypto), reproduzindo encryptField/decryptField byte a byte:
controle (mesma DEK): Adoraria um app nativo
DEK nova -> THROW: Unsupported state or unable to authenticate data
Promise.all REJEITA -> Unsupported state or unable to authenticate data
O controle com a mesma DEK devolve o texto; o Promise.all rejeita de fato, não resolve com undefined.
2. Checagem que mataria o achado: a DEK é determinística? Se getDekForUser derivasse a chave de userId + MEK, o reset devolveria a mesma DEK e nada quebraria. Não é o caso — keys.ts:22 é randomBytes(32), e o COALESCE de :57 não alcança uma linha que acabou de ser deletada. O achado sobreviveu aqui.
3. Enumerei as 21 tabelas do schema contra as 16 que a fase 1 apaga. Sobrevivem: users, accounts, sessions, verificationTokens (NextAuth, nenhuma coluna cifrada por DEK) e feedback. Achado de site único, não multi-site — people.email, investments.notes, userSettings.pixKey e as demais colunas cifradas estão todas em tabelas que a fase 1 apaga.
4. O outro caminho destrutivo do repo faz o certo — e foi documentado fazendo o certo. deleteAccount apaga a linha de users, e feedback.userId é onDelete: 'cascade' (schema.ts:540-542), então o feedback vai junto. O docs/seo-landing-backlog.md §6.1 chega a listar isso explicitamente ("incluindo accounts, sessions, feedback e people") e no parágrafo seguinte contrasta com o resetAccount — sem notar a lacuna. Ou seja: quando alguém raciocinou sobre feedback num caminho destrutivo, a conclusão foi que ele é dado de usuário e precisa sair junto. resetAccount é a minoria, não uma decisão.
5. Convenção/documentação: .claude/domain.md § "Reset de Conta" descreve as 3 fases e a ordem de delete ditada por FK, e não menciona feedback em momento nenhum — enquanto a § "Feedback" do mesmo arquivo registra que feedback.message é cifrado pela DEK do usuário. Nada em .claude/*.md ou em docs/ diz que o feedback deve sobreviver ao reset.
6. git log — inconclusivo, e digo isso em vez de fingir que confirmou. O histórico deste clone está compactado nesse trecho (git log -- lib/db/schema.ts devolve 3 commits; --follow --diff-filter=A em reset-account.ts não alcança o commit de criação). Não deu para ver o commit que escreveu a lista de deletes nem se feedback já existia então. A falsificação de "foi deliberado" se apoia nos itens 4 e 5, não no histórico.
Impacto
Quem clica em "Resetar conta" no SettingsDialog (components/settings/SettingsDialog.tsx:55) tendo enviado feedback antes. Enviar feedback é uma ação de baixa fricção e onipresente — o FeedbackDialog está montado tanto na Sidebar:257 quanto no BottomNav:261, ou seja, em toda tela autenticada. Resetar a conta é a ação natural de quem testou o app e quer começar do zero — exatamente o perfil de beta que mais manda feedback.
Duas consequências, ambas silenciosas no momento em que acontecem:
-
Perda de dado permanente e irrecuperável. As mensagens continuam na tabela, mas a chave que as abre foi destruída. Não há backfill possível: scripts/encrypt-existing-data.ts sabe reparar plaintext e valor já cifrado, não ciphertext órfão. O usuário não percebe (não há tela onde ele leia o próprio feedback) e o admin também não, até abrir o /admin.
-
O /admin inteiro para de funcionar — não só as linhas afetadas. getAllFeedbacks não isola linha: um throw no Promise.all de admin.ts:57 derruba a query, e page.tsx:46 a espera junto com getAdminStats(). O admin vê "algo deu errado" no lugar da caixa de entrada de feedback inteira, de todos os usuários, para sempre. Basta um usuário resetar a conta para o canal de feedback do produto ficar inacessível — e não há como se recuperar pela UI, só apagando as linhas órfãs direto no Neon.
Agrava que feedback é justamente o canal que o /admin existe para servir: o modo de falha remove a única superfície onde o problema seria notado.
Cobertura
Não existe teste que pegue isso, e vale registrar o que o teste existente está garantindo, porque ele dá falsa confiança: __tests__/integration/actions-reset-account.test.ts:42 ("apaga todos os dados financeiros do usuário") enumera 12 tabelas e assere toHaveLength(0) em todas. Ele não afirma o bug (não há asserção errada para corrigir) — é simplesmente cego a ele: nunca cria linha em feedback e nunca decripta nada depois do reset. Passa hoje e continuaria passando com o defeito intacto.
Caso novo, no mesmo arquivo (já tem a maquinaria de branch Neon + DEK real):
it('mantém o feedback legível depois do reset', async () => {
const dekAntiga = await getDekForUser(userId)
await db.insert(schema.feedback).values({
userId, category: 'melhoria', page: '/dashboard',
message: encryptField('Adoraria um app nativo', dekAntiga),
})
await resetAccount()
const [row] = await db.select().from(schema.feedback).where(eq(schema.feedback.userId, userId))
const dekNova = await getDekForUser(userId) // ver a armadilha do cache() abaixo
expect(decryptField(row.message, dekNova)).toBe('Adoraria um app nativo')
})
A asserção precisa ser toBe('<texto original>'), não not.toThrow(). É isso que separa a correção certa das duas erradas mais prováveis:
- fix que simplesmente inclui
feedback na fase 1: a linha some, row é undefined, o teste quebra — que é o resultado desejado, porque apagar o feedback resolve o 500 destruindo o dado que o /admin existe para ler;
- fix que só põe
try/catch em getAllFeedbacks: devolve o sentinela, não o texto, e o teste quebra.
Um expect(...).not.toThrow() passaria nas duas e não cobriria nada.
⚠️ O teste precisa rodar em it() separado do primeiro — actions-reset-account.test.ts já compartilha userId entre os testes e chama resetAccount() no primeiro it, então criar a linha de feedback dentro dele contaminaria as asserções de toHaveLength(0).
Proposta
Recifrar, não apagar. As duas intenções em jogo são legítimas e não conflitam: a rotação de DEK existe para cripto-apagar, e o feedback é dado de produto que o admin precisa continuar lendo. Recifrar atende as duas.
// ANTES da fase 1 — leitura direta de userSettings, NÃO via getDekForUser
const [settings] = await db
.select({ encryptedDek: userSettings.encryptedDek })
.from(userSettings)
.where(eq(userSettings.userId, userId))
const dekAntiga = settings?.encryptedDek ? decryptDek(settings.encryptedDek) : null
const pendentes = dekAntiga
? await db.select({ id: feedback.id, message: feedback.message })
.from(feedback).where(eq(feedback.userId, userId))
: []
// ... fase 1 (delete) e fase 2 (`const dek = await getDekForUser(userId)`) inalteradas ...
// fase 3, junto do seed:
for (const f of pendentes) {
await db.update(feedback)
.set({ message: encryptField(decryptField(f.message, dekAntiga!), dek) })
.where(eq(feedback.id, f.id))
}
Helper nomeado, e por que não o óbvio
Usar decryptDek de lib/crypto/keys.ts:34, lendo userSettings.encryptedDek direto — nunca getDekForUser para capturar a DEK antiga. Este é o ponto que decide se o PR conserta ou destrói a conta:
getDekForUser é embrulhado em cache() do React (keys.ts:47), que memoiza por request. Uma Server Action é um request. Se a correção chamar getDekForUser(userId) no topo para pegar a DEK antiga, a chamada da fase 2 (:99) devolve o mesmo Buffer memoizado — a DEK deletada. As categorias padrão da fase 3 seriam então cifradas com uma chave que não existe mais no banco, e o /dashboard, o /registro e o /categorias do usuário passariam a estourar Unsupported state or unable to authenticate data em todo load. O reset deixaria a conta pior do que este bug deixa o /admin.
getDekForUser no topo é o caminho natural — é o helper que todo o resto do repo usa. Aqui ele é exatamente o errado.
decryptField/encryptField (e não decryptOptional/encryptOptional) porque feedback.message é .notNull() (schema.ts:544) — decryptOptional só serviria para mascarar um null que o schema não permite.
Segunda metade: getAllFeedbacks não deve cair por causa de uma linha
Independente da correção acima, lib/queries/admin.ts:54 precisa degradar por linha em vez de derrubar a página — as linhas já órfãs em produção continuam órfãs depois do fix, e nenhum reset futuro deve poder tirar o /admin do ar:
try {
return decryptField(message, dek)
} catch (err) {
console.error('[getAllFeedbacks] mensagem ilegível', { feedbackId, err })
return '[mensagem ilegível — chave rotacionada]'
}
O console.error é o que hoje não existe: sem ele, a linha órfã não deixa rastro em lugar nenhum (o projeto não tem Sentry).
Ordem de execução sugerida: o try/catch primeiro, sozinho, porque destrava o /admin de imediato para eventuais linhas já órfãs; a recifragem em seguida, que é a correção de verdade.
Custo estimado
M (3 arquivos): lib/actions/reset-account.ts, lib/queries/admin.ts, __tests__/integration/actions-reset-account.test.ts.
Onde
lib/actions/reset-account.ts:66-123— a função inteira; o ponto exato é:89(delete deuserSettings) combinado com:99(provisionamento de DEK nova).Consumidor que quebra:
lib/queries/admin.ts:49-67→app/(app)/admin/page.tsx:46.Evidência
resetAccountapagauserSettings— que é onde moraencryptedDek— dentro da transaction da fase 1:…e a fase 2 provisiona uma DEK nova:
A DEK não é derivada de nada — é aleatória (
lib/crypto/keys.ts:21-23):O
COALESCEdegetDekForUser(keys.ts:57) só preserva DEK de uma linha que existe. Como a fase 1 apagou a linha, oINSERTgrava a chave nova e a antiga desaparece — não há cópia em lugar nenhum.O problema é o que sobra. A fase 1 apaga 16 tabelas. Comparando com as 21
pgTabledo schema,feedbacké a única tabela com coluna cifrada por DEK que sobrevive ao reset:Depois do reset, essas linhas continuam cifradas com a DEK que foi destruída. Aí entra o contrato que mente:
decryptFieldnão degrada:decipher.final()(lib/crypto/fields.ts:24) lança quando o auth tag do GCM não confere. Um únicothrowdentro doPromise.allde:57rejeita a query inteira, eapp/(app)/admin/page.tsx:46a chama dentro de outroPromise.all— a página cai noapp/(app)/error.tsx.O tipo é verdade sintática e mentira semântica:
getAllFeedbacks(): Promise<{ message: string, ... }[]>promete texto claro. O contrato real, não escrito em lugar nenhum, é "a DEK atual do usuário é a mesma que cifrou esta linha" — eresetAccounté exatamente o código que rompe essa invariante, sem que nada no compilador ou no schema o registre.Verificações feitas (tentativa de falsificar o achado)
1. Rodei o crypto real (
node, semnode_modules—fields.tsusa sónode:crypto), reproduzindoencryptField/decryptFieldbyte a byte:O controle com a mesma DEK devolve o texto; o
Promise.allrejeita de fato, não resolve comundefined.2. Checagem que mataria o achado: a DEK é determinística? Se
getDekForUserderivasse a chave deuserId+ MEK, o reset devolveria a mesma DEK e nada quebraria. Não é o caso —keys.ts:22érandomBytes(32), e oCOALESCEde:57não alcança uma linha que acabou de ser deletada. O achado sobreviveu aqui.3. Enumerei as 21 tabelas do schema contra as 16 que a fase 1 apaga. Sobrevivem:
users,accounts,sessions,verificationTokens(NextAuth, nenhuma coluna cifrada por DEK) efeedback. Achado de site único, não multi-site —people.email,investments.notes,userSettings.pixKeye as demais colunas cifradas estão todas em tabelas que a fase 1 apaga.4. O outro caminho destrutivo do repo faz o certo — e foi documentado fazendo o certo.
deleteAccountapaga a linha deusers, efeedback.userIdéonDelete: 'cascade'(schema.ts:540-542), então o feedback vai junto. Odocs/seo-landing-backlog.md§6.1 chega a listar isso explicitamente ("incluindoaccounts,sessions,feedbackepeople") e no parágrafo seguinte contrasta com oresetAccount— sem notar a lacuna. Ou seja: quando alguém raciocinou sobre feedback num caminho destrutivo, a conclusão foi que ele é dado de usuário e precisa sair junto.resetAccounté a minoria, não uma decisão.5. Convenção/documentação:
.claude/domain.md§ "Reset de Conta" descreve as 3 fases e a ordem de delete ditada por FK, e não mencionafeedbackem momento nenhum — enquanto a § "Feedback" do mesmo arquivo registra quefeedback.messageé cifrado pela DEK do usuário. Nada em.claude/*.mdou emdocs/diz que o feedback deve sobreviver ao reset.6.
git log— inconclusivo, e digo isso em vez de fingir que confirmou. O histórico deste clone está compactado nesse trecho (git log -- lib/db/schema.tsdevolve 3 commits;--follow --diff-filter=Aemreset-account.tsnão alcança o commit de criação). Não deu para ver o commit que escreveu a lista de deletes nem sefeedbackjá existia então. A falsificação de "foi deliberado" se apoia nos itens 4 e 5, não no histórico.Impacto
Quem clica em "Resetar conta" no
SettingsDialog(components/settings/SettingsDialog.tsx:55) tendo enviado feedback antes. Enviar feedback é uma ação de baixa fricção e onipresente — oFeedbackDialogestá montado tanto naSidebar:257quanto noBottomNav:261, ou seja, em toda tela autenticada. Resetar a conta é a ação natural de quem testou o app e quer começar do zero — exatamente o perfil de beta que mais manda feedback.Duas consequências, ambas silenciosas no momento em que acontecem:
Perda de dado permanente e irrecuperável. As mensagens continuam na tabela, mas a chave que as abre foi destruída. Não há backfill possível:
scripts/encrypt-existing-data.tssabe reparar plaintext e valor já cifrado, não ciphertext órfão. O usuário não percebe (não há tela onde ele leia o próprio feedback) e o admin também não, até abrir o/admin.O
/admininteiro para de funcionar — não só as linhas afetadas.getAllFeedbacksnão isola linha: umthrownoPromise.alldeadmin.ts:57derruba a query, epage.tsx:46a espera junto comgetAdminStats(). O admin vê "algo deu errado" no lugar da caixa de entrada de feedback inteira, de todos os usuários, para sempre. Basta um usuário resetar a conta para o canal de feedback do produto ficar inacessível — e não há como se recuperar pela UI, só apagando as linhas órfãs direto no Neon.Agrava que
feedbacké justamente o canal que o/adminexiste para servir: o modo de falha remove a única superfície onde o problema seria notado.Cobertura
Não existe teste que pegue isso, e vale registrar o que o teste existente está garantindo, porque ele dá falsa confiança:
__tests__/integration/actions-reset-account.test.ts:42("apaga todos os dados financeiros do usuário") enumera 12 tabelas e asseretoHaveLength(0)em todas. Ele não afirma o bug (não há asserção errada para corrigir) — é simplesmente cego a ele: nunca cria linha emfeedbacke nunca decripta nada depois do reset. Passa hoje e continuaria passando com o defeito intacto.Caso novo, no mesmo arquivo (já tem a maquinaria de branch Neon + DEK real):
A asserção precisa ser
toBe('<texto original>'), nãonot.toThrow(). É isso que separa a correção certa das duas erradas mais prováveis:feedbackna fase 1: a linha some,rowéundefined, o teste quebra — que é o resultado desejado, porque apagar o feedback resolve o 500 destruindo o dado que o/adminexiste para ler;try/catchemgetAllFeedbacks: devolve o sentinela, não o texto, e o teste quebra.Um
expect(...).not.toThrow()passaria nas duas e não cobriria nada.it()separado do primeiro —actions-reset-account.test.tsjá compartilhauserIdentre os testes e chamaresetAccount()no primeiroit, então criar a linha de feedback dentro dele contaminaria as asserções detoHaveLength(0).Proposta
Recifrar, não apagar. As duas intenções em jogo são legítimas e não conflitam: a rotação de DEK existe para cripto-apagar, e o feedback é dado de produto que o admin precisa continuar lendo. Recifrar atende as duas.
Helper nomeado, e por que não o óbvio
Usar
decryptDekdelib/crypto/keys.ts:34, lendouserSettings.encryptedDekdireto — nuncagetDekForUserpara capturar a DEK antiga. Este é o ponto que decide se o PR conserta ou destrói a conta:getDekForUseré embrulhado emcache()do React (keys.ts:47), que memoiza por request. Uma Server Action é um request. Se a correção chamargetDekForUser(userId)no topo para pegar a DEK antiga, a chamada da fase 2 (:99) devolve o mesmo Buffer memoizado — a DEK deletada. As categorias padrão da fase 3 seriam então cifradas com uma chave que não existe mais no banco, e o/dashboard, o/registroe o/categoriasdo usuário passariam a estourarUnsupported state or unable to authenticate dataem todo load. O reset deixaria a conta pior do que este bug deixa o/admin.getDekForUserno topo é o caminho natural — é o helper que todo o resto do repo usa. Aqui ele é exatamente o errado.decryptField/encryptField(e nãodecryptOptional/encryptOptional) porquefeedback.messageé.notNull()(schema.ts:544) —decryptOptionalsó serviria para mascarar um null que o schema não permite.Segunda metade:
getAllFeedbacksnão deve cair por causa de uma linhaIndependente da correção acima,
lib/queries/admin.ts:54precisa degradar por linha em vez de derrubar a página — as linhas já órfãs em produção continuam órfãs depois do fix, e nenhum reset futuro deve poder tirar o/admindo ar:O
console.erroré o que hoje não existe: sem ele, a linha órfã não deixa rastro em lugar nenhum (o projeto não tem Sentry).Ordem de execução sugerida: o
try/catchprimeiro, sozinho, porque destrava o/adminde imediato para eventuais linhas já órfãs; a recifragem em seguida, que é a correção de verdade.Custo estimado
M (3 arquivos):
lib/actions/reset-account.ts,lib/queries/admin.ts,__tests__/integration/actions-reset-account.test.ts.