diff --git a/docs/superpowers/specs/2026-06-19-curadoria-assinatura-claude-design.md b/docs/superpowers/specs/2026-06-19-curadoria-assinatura-claude-design.md new file mode 100644 index 0000000..95086e6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-curadoria-assinatura-claude-design.md @@ -0,0 +1,248 @@ +# Auto-curadoria do cérebro via assinatura Claude do usuário (2026-06-19) + +Contexto: o perfil curado de memória (spec `2026-06-18-memoria-perfil-curado*`) +já está no ar para todas as contas, com um curador determinístico (confiança, +decaimento, promoção) que **mantém** os fatos mas não os **cria** — fatos só +entram manualmente pelo portal. A fase 2 era "extração LLM", e a decisão do Bruno +(2026-06-19) é fazê-la **com a IA da própria pessoa**, não com uma chave Anthropic +central (custo central + créditos zerados). A maioria dos amigos usa **assinatura +Claude** (Claude Code / Pro / Max). + +O **Odysseus** já resolve "usar a assinatura via OAuth" (provider Bearer + +`oauth-2025-04-20`); este spec **porta esse mecanismo** para o engine e o usa para +um agente server-side, mantido por nós, que **cura o perfil de cada conta com a +conexão Claude dela** — o cérebro se mantém sozinho, no custo da pessoa. + +> **Premissa de ToS, registrada:** usar OAuth de assinatura para automação +> server-side é uma zona cinzenta da Anthropic (a doc oficial direciona cargas +> não-interativas para API key/WIF). O Bruno já carrega esse tradeoff no Odysseus +> (stack self-hosted, amigos, decisão dele). Este spec assume essa decisão; não a +> re-litiga. Mitigação: opt-in explícito (a pessoa conecta), por conta, gated, +> com kill-switch. + +## Mecanismo reutilizado do Odysseus (verificado no código) +Refs em `~/dev/odysseus`: +- **Connect = paste manual.** O callback OAuth PKCE do `claude setup-token` é + loopback (localhost), não funciona em servidor remoto. Então a pessoa roda o + fluxo localmente e **cola a credencial**. Parser aceita: bare `sk-ant-oat01-…`, + JSON do Claude Code (`{"claudeAiOauth":{accessToken,refreshToken,expiresAt}}`), + e flat snake_case. (`src/claude_subscription.py:138-170`) +- **Refresh:** quando `expires_at <= agora+300s` e há `refresh_token`, POST em + `https://platform.claude.com/v1/oauth/token` com `{grant_type:"refresh_token", + refresh_token, client_id:"9d1c250a-e61b-44d9-88ed-5944d1962f5e"}`; atualiza + tokens+expiry. Lock por auth. (`:207-299`) +- **Headers da chamada:** `anthropic-version: 2023-06-01`, **`anthropic-beta: + oauth-2025-04-20`**, **`Authorization: Bearer `** (NÃO `x-api-key`), + contra `api.anthropic.com/v1/messages`. (`src/claude_subscription.py:125-133`, + `src/llm_core.py:1167-1201`) +- **Spoof obrigatório:** injeta 2 blocos de `system` ANTES do system do usuário, + o 1º = `"You are Claude Code, Anthropic's official CLI for Claude."`, o 2º = + guidance. Sem a identidade de Claude Code, a Anthropic dá **soft-block 429** em + Opus/Sonnet via OAuth de assinatura. Para modelos capazes, também seta + `output_config.effort:"high"` e `thinking:{type:"adaptive"}`. + (`src/llm_core.py:1017-1146`) +- **Erros:** 401 no refresh → reauth ("reconectar"); 429 → rate limited. + +## Objetivo (uma frase) +Dar ao engine um **provider de assinatura Claude por conta** (connect via paste + +refresh + chamada com o spoof de Claude Code) e um **agente de auto-curadoria** no +cron que, para cada conta com assinatura conectada, usa a conexão dela para extrair +e fundir fatos das memórias de conversa, alimentando a escada de confiança do +perfil — tudo gated, opt-in, e no custo da pessoa. + +## Decisões de design (com tradeoff) + +### D1. Provider de assinatura: novo módulo `src/claude-subscription.ts` +Porta as funções puras do Odysseus: `parsePastedCredential(text) -> +{access_token, refresh_token?, expires_at}`, `oauthHeaders(access_token)`, +`buildSubscriptionSystemBlocks(userSystem) -> SystemBlock[]` (injeta os 2 blocos +de spoof antes do system do usuário), `needsRefresh(expires_at, now)`, e +`refreshSubscription(refresh_token) -> {...}` (POST ao token endpoint). +- Constantes (client_id, token URL, beta header, system spoof) centralizadas e + cobertas por teste. URLs **fixas** (`platform.claude.com`/`api.anthropic.com`), + nunca deriváveis do paste/body. `fetch` nativo (zero-dep). +- **Expiry tem DUAS conversões (fiel ao Odysseus, senão loop de refresh):** + (a) no parse, `expiresAt` é epoch com heurística ms-vs-s: `secs = raw>1e12 ? + raw/1000 : raw` (`claude_subscription.py:163`). (b) no refresh, a resposta traz + `expires_in` (delta em segundos), NÃO `expiresAt` absoluto → novo `expires_at = + now + expires_in` (`:285-287`). Preservar o `refresh_token` antigo quando a + resposta não traz um novo (`:283-284`). +- **Bare token (`sk-ant-oat01-…`, sem expiry/refresh):** aplicar TTL default no + connect (ex.: 365d, `CLAUDE_DEFAULT_TOKEN_TTL_DAYS`) e a guarda "só tenta + refresh se há `refresh_token`" (`:273`); senão o status reporta sempre-expirado + ou tenta refresh sem token. + +### D2. Storage no vault existente (`account_secrets`, kind `claude_oauth`) +Reusa `src/secrets.ts` + `src/crypto-utils.ts` (Fernet/AES já em uso). Sem tabela +nova. Por `account_id`. Conteúdo: JSON `{access_token, refresh_token, expires_at, +auth_mode:"claude"}` criptografado em repouso. +- **Tradeoff:** o vault já guarda PATs do Notion/Granola; a credencial de + assinatura é mais poderosa (roda LLM na conta da pessoa), mas o vault é o lugar + certo e já criptografado. **NUNCA** logar/retornar o token; portal mostra só + status (conectado, expira em, reconectar). + +### D3. `callSubscription(accountId, {system, messages, model, maxTokens, ...})` +A chamada central: resolve a credencial do vault, monta headers OAuth + payload +com os blocos de spoof, chama `/v1/messages`. Erros 401→`SubscriptionReauthRequired`, +429→`SubscriptionRateLimited`. +- **`accountId` é parâmetro EXPLÍCITO; NUNCA `getAccountId()`** (que faz fallback + pra `'bruno'` fora de request-context → usaria a assinatura/contabilidade da + conta errada). A conta A só resolve a própria credencial. +- **Refresh com lock CROSS-PROCESS** (3 processos PM2 no mesmo Postgres + rotação + de refresh token): a resolução roda numa transação com + `pg_advisory_xact_lock(hashtext('claude_refresh:'||accountId))` (ou `SELECT … + FOR UPDATE` na linha do vault), que (a) re-lê a credencial sob o lock, (b) + re-checa expiry, (c) faz refresh, (d) grava. Lock in-process (`Map`) + NÃO basta. (É padrão novo no engine — advisory lock não é usado hoje.) +- **`max_tokens` é obrigatório** no payload (default ex.: 2048 pra extração). + `system` vai como **array de blocos** `{type:"text",text}`. +- **Effort/thinking gated por versão de modelo** (`supportsEffort(model)`: Opus + ≥4.5, Sonnet ≥4.6, Fable): só então `output_config.effort:"high"` + + `thinking:{type:"adaptive"}`, e nesse caso **omitir `temperature`** (incompatível). + Default `claude-opus-4-8` (acima do corte). +- **Nunca logar o token:** erros 401/429 logam sem a substring do access_token. + +### D4. Agente de auto-curadoria: novo extrator no curador, powered by assinatura +Estende `src/rag/memory-curator.ts` (ou módulo irmão `memory-extractor.ts`): +para cada conta **com assinatura conectada** (e flag on), seleciona memórias de +conversa recentes não-processadas, chama `callSubscription` com um prompt de +extração ("extraia fatos duráveis e estáveis sobre o usuário; ignore efêmero"), +e cada fato vira **sinal** (`status='signal'`, `source='llm'`) em +`user_profile_facts` (a tabela e a escada já existem). A fusão de fatos +sobrepostos também roda por aí. +- **Isolamento por conta (obrigatório, espelha `runEntityExtraction`):** cada + conta é processada DENTRO de `requestContext.run({authType:'bearer', scopes:[], + accountId, isOperator:false}, () => …)`, e `callSubscription` recebe o + `accountId` explícito. Sem isso, helpers internos (`getAccountId()`, + metering/auditoria) caem no fallback `'bruno'` → vazamento de conta. A + resolução da credencial é sempre da conta processada (a conta A nunca lê o + `claude_oauth` de B). +- **Determinístico continua mandando:** a escada de confiança (Wilson/ratio + + decaimento) e a guarda de segredo governam o que é promovido/injetado. O LLM só + **propõe**; ele não escreve direto no perfil injetado. +- **Marcação de processado:** cursor por conta (ex.: `last_extracted_at` em + `account_secrets` kind `memory_extract_cursor`, ou coluna em sync_state) pra não + reprocessar conversas. Orçamento por run (N memórias/conta) espelhando + `runEntityExtraction`. + +### D5. Guarda de segredo + auditoria (reusa o que existe) +Todo fato extraído passa por `looksLikeSecret` (descarta) e `stripSecrets` +(`profile-guard.ts`) **no `content` antes do upsert** (pega um access token +`sk-ant-oat01-…` que o modelo por acaso ecoe). Cada inserção/transição → +`memory_audit` (trigger `llm`). +- **`evidence_ref` é PONTEIRO tipado** (`{chunkId?}`/source_id), nunca conteúdo + cru nem token. Não logar o token em erro do `callSubscription`. +- **Honestidade de PII:** `looksLikeSecret` filtra **segredos de máquina**, NÃO + PII. O `content` do fato vem de conversas e é dado pessoal **em claro** na + tabela (que não é criptografada — igual `brain_chunks`/`remember` já são). A + mitigação é a guarda de segredo + orçamento de caracteres + (futuro) + confirmação no portal; não fingimos filtrar PII. + +### D6. Gating + opt-in + isolamento +- Conectar a assinatura é **opt-in** (a pessoa cola a credencial). Sem conexão → + o agente pula a conta (continua só o determinístico). +- Flag global `MEMORY_EXTRACT_ENABLED` (kill-switch). Sem ela, nada extrai. +- **Isolamento:** a credencial e os fatos são sempre `account_id`-scoped (vault + + tabela). A conta A nunca usa a assinatura nem vê os fatos da conta B. + +### D7. Connect/Disconnect/Status no portal +Rotas em `src/portal/routes.ts`, **account sempre de `res.locals.accountId`** +(sessão, nunca body/query — padrão `requireSession`): `POST /portal/claude/connect` +(valida que o body é string, com **teto de tamanho** ex. 16 KB, antes de +`JSON.parse`/parse → vault), `POST /portal/claude/disconnect` +(`deleteAccountSecret` kind `claude_oauth`), `GET /portal/claude/status` +(conectado? expira? precisa reconectar? — **nunca** expõe o token cru). +- **Disconnect = esquecer localmente:** o engine para de usar no próximo resolve; + runs em voo terminam o batch; **não revoga** o token no provedor (a pessoa + revoga a sessão no Claude pra kill imediato). Declarado. +- UI: card "Conexão Claude" na página Perfil do portal Next.js + (`web/app/(app)/perfil/`), com instruções (rode `claude setup-token`, cole aqui), + estado, e o expiry (sintético p/ bare token). + +## Modelo de dados +Sem tabela nova. Reusa: +- `account_secrets` kind `claude_oauth` (credencial, criptografada). +- `account_secrets` kind `memory_extract_cursor` (ou `sync_state`) para o cursor. +- `user_profile_facts` + `memory_audit` (migração 0019, já existem). + +## Arquitetura do fluxo +``` +CONNECT (portal, paste) REFRESH (lazy, no resolve) EXTRAÇÃO (cron, por conta) +claude setup-token (local) expires<=now+300s + refresh_token para cada conta c/ assinatura: + → cola credencial ──vault──> → POST token endpoint callSubscription(acct, prompt+spoof) + (account_secrets → atualiza vault → fatos candidatos + kind claude_oauth) → looksLikeSecret? descarta + → upsert signal em user_profile_facts + → memory_audit (trigger 'llm') + ↓ + escada determinística (já existe) promove → injeção (já existe) +``` + +## Critérios de aceite (verificáveis por máquina) +`npm test` verde (puro/deps injetadas; sem rede/DB real). Nenhum AC depende de +teste que dá early-return sem `POSTGRES_URL`. +1. **Parser (puro):** `parsePastedCredential` lê os 3 formatos (bare, JSON Claude + Code, flat) e extrai access/refresh/expires; lixo → erro claro. +2. **Headers (puro):** `oauthHeaders` produz `Authorization: Bearer …` + + `anthropic-beta: oauth-2025-04-20` + `anthropic-version`; **nunca** `x-api-key`. +3. **Spoof (puro):** `buildSubscriptionSystemBlocks(userSystem)` começa com o bloco + `"You are Claude Code, …"` e preserva o system do usuário depois; ordem correta. +4. **Refresh (puro/deps):** `needsRefresh` respeita o skew de 300s e o caso + bare-token (sem refresh_token → não tenta refresh); `parsePastedCredential` + aplica a heurística ms-vs-s (`>1e12`→ms); `refreshSubscription` monta + `grant_type=refresh_token` + client_id, calcula novo expiry de `expires_in` + (delta), e **preserva o refresh_token antigo** quando a resposta não traz um. +4b. **Refresh concorrente (deps):** dois `resolve` simultâneos da mesma conta → + **um** refresh, ambos terminam com o mesmo token (lock cross-process; teste + com fake de lock/pool que prova a serialização). +5. **callSubscription (deps injetadas):** com fetch+vault fakes — manda `Bearer` + (não `x-api-key`) + `oauth-2025-04-20`, injeta o spoof, inclui `max_tokens`, + só seta `effort`/omite `temperature` em modelo capaz (`supportsEffort`), + recebe `accountId` explícito (não `getAccountId()`); 401→`SubscriptionReauthRequired`; + um erro logado **não contém** o access_token. +6. **Extrator (deps injetadas):** dado memórias fake e um `callSubscription` fake + que devolve fatos, gera **sinais** em `user_profile_facts` (SQL-shape, com + `account_id`), roda cada conta dentro de `requestContext.run({accountId})`, + passa o `content` por `looksLikeSecret`/`stripSecrets` (fato com `sk-ant-…` é + descartado/redigido e **não** vai pro `evidence_ref`), grava `memory_audit` + trigger `llm` com `evidence_ref` = ponteiro, e respeita o cursor. +7. **Isolamento:** o extrator processando a conta A nunca **lê a credencial** + `claude_oauth` nem escreve fato com `account_id` de B; conta sem assinatura é + pulada. +8. **Gating:** com `MEMORY_EXTRACT_ENABLED` off, o extrator não chama `callSubscription`. +9. **Vault (SQL-shape):** connect grava kind `claude_oauth` account-scoped + criptografado; status nunca expõe o token cru. + +Pós-deploy (owner-first): +10. Bruno conecta a própria assinatura no portal; `GET /portal/claude/status` = + conectado; um run do extrator gera ≥1 sinal no perfil dele a partir de uma + memória de conversa; `/health` 200, `zinom.ai/mcp` 401. + +## Fora de escopo (registrado) +- OAuth PKCE hospedado (callback no servidor) em vez de paste — futuro; paste + espelha o Odysseus e é o que funciona hoje. +- Extração com chave Anthropic central (a fase-2 original) — substituída por esta. +- Agente agêntico completo (multi-step, tools) por usuário — isto aqui é extração + por chamada única; o "Hermes por usuário" é uma aposta maior, spec própria. +- Patch do Odysseus pro 3º surface (decisão anterior: dropado por conflitar com a + memória nativa do Odysseus). + +## Riscos +- **Custódia de credencial poderosa:** vault criptografado, account-scoped, nunca + logada; disconnect apaga. Blast radius se o vault vazar (já vale para PATs, mas + esta é mais sensível). +- **ToS/soft-block:** sem o spoof de Claude Code → 429; com ele, ainda é uso + não-sancionado de assinatura para automação. Aceito (decisão Odysseus). +- **Refresh/expiry:** token de assinatura é curto; refresh lazy + reauth claro no + portal. Se o refresh falhar, a conta volta a só-determinístico (degrada, não quebra). +- **Custo/rate limit na conta da pessoa:** orçamento por run + cron espaçado; + 429 da conta → pula e tenta no próximo ciclo. +- **Alucinação na extração:** o LLM só propõe sinais; a escada + guarda de segredo + + (futuro) confirmação no portal filtram antes de injetar. + +## Rollout (gated, opt-in) +1. Provider + vault + connect/status no portal (sem extrair ainda). Bruno conecta. +2. Extrator gated `MEMORY_EXTRACT_ENABLED`, owner-first: liga, observa + `memory_audit`/`status_runs` e a qualidade dos sinais. +3. Abre o connect para friends; cada um que conectar entra no ciclo. Calibra + orçamento/cadência. diff --git a/src/__tests__/claude-subscription-store.test.ts b/src/__tests__/claude-subscription-store.test.ts new file mode 100644 index 0000000..f567eec --- /dev/null +++ b/src/__tests__/claude-subscription-store.test.ts @@ -0,0 +1,451 @@ +// src/__tests__/claude-subscription-store.test.ts +// Storage + chamada central da assinatura Claude. SEM DB/rede real: pool, fetch e +// o lock cross-process (withAccountLock) são fakes injetados. Cobre AC 4b +// (refresh concorrente serializa em UM refresh), AC5 (callSubscription manda +// Bearer não x-api-key, spoof, max_tokens, effort só em modelo capaz, accountId +// explícito, 401->reauth, token nunca logado) e AC9 (vault account-scoped, kind +// claude_oauth, status nunca expõe token). +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { __setPoolForTest } from "../rag/storage.js"; +import { + loadCredential, + saveCredential, + deleteCredential, + resolveAccessToken, + callSubscription, + SubscriptionNotConnected, + SubscriptionReauthRequired, + SubscriptionRateLimited, + type StoredCredential, +} from "../claude-subscription-store.js"; + +const HEXKEY = "ab".repeat(32); + +// Async-aware: keeps SECRETS_KEY set for the WHOLE awaited operation (the +// secrets layer reads it lazily at decrypt time). A sync finally would restore +// it before the awaited crypto runs. +async function withKey(fn: () => Promise | T): Promise { + const prev = process.env.SECRETS_KEY; + process.env.SECRETS_KEY = HEXKEY; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.SECRETS_KEY; + else process.env.SECRETS_KEY = prev; + } +} + +afterEach(() => __setPoolForTest(null)); + +// A trivial in-process lock that proves serialization for the concurrency test: +// only one holder at a time, the second awaits the first's release. +function makeSerializingLock() { + let chain: Promise = Promise.resolve(); + const order: string[] = []; + const withAccountLock = async (_accountId: string, fn: () => Promise): Promise => { + const prev = chain; + let release!: () => void; + chain = new Promise((r) => (release = r)); + await prev; + order.push("enter"); + try { + return await fn(); + } finally { + order.push("exit"); + release(); + } + }; + return { withAccountLock, order }; +} + +// --- AC9: vault (SQL-shape) ------------------------------------------------- + +test("AC9: saveCredential grava kind claude_oauth account-scoped, criptografado", async () => { + let sql = ""; + let params: unknown[] = []; + __setPoolForTest({ + query: async (q: string, p: unknown[]) => { + sql = q; + params = p; + return { rows: [], rowCount: 1 }; + }, + } as never); + const cred: StoredCredential = { + access_token: "sk-ant-oat01-SAVE", + refresh_token: "rt-1", + expires_at: new Date("2027-01-01T00:00:00.000Z"), + auth_mode: "claude", + }; + await withKey(() => saveCredential("acme", cred)); + assert.match(sql, /INSERT INTO account_secrets/i); + assert.match(sql, /ON CONFLICT \(account_id, kind\)/i); + assert.equal(params[0], "acme"); + assert.equal(params[1], "claude_oauth"); + // valor criptografado (envelope v1), nunca o token em claro + assert.match(String(params[2]), /^v1:/); + assert.doesNotMatch(String(params[2]), /sk-ant-oat01-SAVE/); +}); + +test("AC9/loadCredential: decripta o JSON e null quando ausente", async () => { + // Primeiro grava (captura o blob), depois lê de volta. + let stored = ""; + __setPoolForTest({ + query: async (_q: string, p: unknown[]) => { + stored = String(p[2]); + return { rows: [], rowCount: 1 }; + }, + } as never); + const cred: StoredCredential = { + access_token: "sk-ant-oat01-RT", + refresh_token: "rt-RT", + expires_at: new Date("2027-03-01T00:00:00.000Z"), + auth_mode: "claude", + }; + await withKey(() => saveCredential("acme", cred)); + + __setPoolForTest({ + query: async () => ({ rows: [{ enc_value: stored }], rowCount: 1 }), + } as never); + const loaded = await withKey(() => loadCredential("acme")); + assert.ok(loaded); + assert.equal(loaded!.access_token, "sk-ant-oat01-RT"); + assert.equal(loaded!.refresh_token, "rt-RT"); + assert.equal(loaded!.expires_at.getTime(), cred.expires_at.getTime()); + assert.equal(loaded!.auth_mode, "claude"); + + __setPoolForTest({ query: async () => ({ rows: [], rowCount: 0 }) } as never); + assert.equal(await withKey(() => loadCredential("acme")), null); +}); + +test("deleteCredential apaga o kind claude_oauth da conta", async () => { + let sql = ""; + let params: unknown[] = []; + __setPoolForTest({ + query: async (q: string, p: unknown[]) => { + sql = q; + params = p; + return { rows: [], rowCount: 1 }; + }, + } as never); + await deleteCredential("acme"); + assert.match(sql, /DELETE FROM account_secrets/i); + assert.deepEqual(params, ["acme", "claude_oauth"]); +}); + +// --- resolveAccessToken: sem credencial -> SubscriptionNotConnected --------- + +test("resolveAccessToken sem credencial -> SubscriptionNotConnected", async () => { + const { withAccountLock } = makeSerializingLock(); + await withKey(() => + assert.rejects( + () => + resolveAccessToken("acme", { + withAccountLock, + loadFn: async () => null, + saveFn: async () => {}, + fetchImpl: (async () => { + throw new Error("não deveria chamar fetch"); + }) as unknown as typeof fetch, + }), + (e) => e instanceof SubscriptionNotConnected, + ), + ); +}); + +// --- resolveAccessToken: refresh quando expirado, salva --------------------- + +test("resolveAccessToken faz refresh quando expirado e SALVA o token novo", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const expired: StoredCredential = { + access_token: "sk-ant-oat01-OLD", + refresh_token: "rt-OLD", + expires_at: new Date(now.getTime() - 1000), // já expirado + auth_mode: "claude", + }; + let saved: StoredCredential | null = null; + const { withAccountLock } = makeSerializingLock(); + const fetchImpl = (async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "sk-ant-oat01-FRESH", expires_in: 3600 }), + })) as unknown as typeof fetch; + + const token = await resolveAccessToken("acme", { + withAccountLock, + loadFn: async () => expired, + saveFn: async (_a, c) => { + saved = c; + }, + fetchImpl, + now, + }); + assert.equal(token, "sk-ant-oat01-FRESH"); + assert.ok(saved); + assert.equal(saved!.access_token, "sk-ant-oat01-FRESH"); + // refresh_token antigo preservado (resposta não trouxe um novo) + assert.equal(saved!.refresh_token, "rt-OLD"); + assert.equal(saved!.expires_at.getTime(), now.getTime() + 3600 * 1000); +}); + +test("resolveAccessToken NÃO refresca token ainda válido (retorna o atual)", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const valid: StoredCredential = { + access_token: "sk-ant-oat01-VALID", + refresh_token: "rt-V", + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + let fetchCalls = 0; + const token = await resolveAccessToken("acme", { + withAccountLock, + loadFn: async () => valid, + saveFn: async () => {}, + fetchImpl: (async () => { + fetchCalls++; + return { ok: true, status: 200, json: async () => ({}) }; + }) as unknown as typeof fetch, + now, + }); + assert.equal(token, "sk-ant-oat01-VALID"); + assert.equal(fetchCalls, 0); // não chamou o token endpoint +}); + +test("resolveAccessToken: 401 no refresh -> SubscriptionReauthRequired", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const expired: StoredCredential = { + access_token: "sk-ant-oat01-OLD", + refresh_token: "rt-OLD", + expires_at: new Date(now.getTime() - 1000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + await assert.rejects( + () => + resolveAccessToken("acme", { + withAccountLock, + loadFn: async () => expired, + saveFn: async () => {}, + fetchImpl: (async () => ({ + ok: false, + status: 401, + json: async () => ({ error: "invalid_grant" }), + })) as unknown as typeof fetch, + now, + }), + (e) => e instanceof SubscriptionReauthRequired, + ); +}); + +// --- AC4b: refresh concorrente -> UM refresh, mesmo token ------------------- + +test("AC4b: dois resolves simultâneos da mesma conta -> UM refresh, mesmo token", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + // Estado mutável compartilhado: o primeiro resolve refresca e GRAVA; o segundo, + // sob o lock, relê o token já fresco e não refresca de novo. + let current: StoredCredential = { + access_token: "sk-ant-oat01-OLD", + refresh_token: "rt-OLD", + expires_at: new Date(now.getTime() - 1000), + auth_mode: "claude", + }; + let refreshCount = 0; + const { withAccountLock, order } = makeSerializingLock(); + const fetchImpl = (async () => { + refreshCount++; + return { + ok: true, + status: 200, + json: async () => ({ access_token: "sk-ant-oat01-FRESH", expires_in: 3600 }), + }; + }) as unknown as typeof fetch; + const deps = { + withAccountLock, + loadFn: async () => current, + saveFn: async (_a: string, c: StoredCredential) => { + current = c; + }, + fetchImpl, + now, + }; + + const [a, b] = await Promise.all([ + resolveAccessToken("acme", deps), + resolveAccessToken("acme", deps), + ]); + assert.equal(refreshCount, 1, "deve refrescar exatamente uma vez (lock serializa)"); + assert.equal(a, "sk-ant-oat01-FRESH"); + assert.equal(b, "sk-ant-oat01-FRESH"); + // lock serializou: enter/exit não se intercalam (enter,exit,enter,exit) + assert.deepEqual(order, ["enter", "exit", "enter", "exit"]); +}); + +// --- AC5: callSubscription -------------------------------------------------- + +function fakeOkMessages(text = "fato extraído") { + return (async (_url: string, init: any) => ({ + ok: true, + status: 200, + json: async () => ({ content: [{ type: "text", text }] }), + _init: init, + })) as unknown as typeof fetch; +} + +test("AC5: callSubscription manda Bearer (não x-api-key), spoof, max_tokens; recebe accountId explícito", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + let captured: any = null; + const fetchImpl = (async (_url: string, init: any) => { + captured = init; + return { ok: true, status: 200, json: async () => ({ content: [{ type: "text", text: "ok" }] }) }; + }) as unknown as typeof fetch; + + const valid: StoredCredential = { + access_token: "sk-ant-oat01-CALL", + refresh_token: null, + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + const text = await callSubscription( + "acme", + { + system: "Você é o curador.", + messages: [{ role: "user", content: "olá" }], + model: "claude-haiku-4-5", // NÃO suporta effort + maxTokens: 512, + }, + { withAccountLock, loadFn: async () => valid, saveFn: async () => {}, fetchImpl, now }, + ); + assert.equal(text, "ok"); + + // headers: Bearer, oauth-beta; nunca x-api-key + const headers = captured.headers as Record; + assert.equal(headers["Authorization"], "Bearer sk-ant-oat01-CALL"); + assert.equal(headers["anthropic-beta"], "oauth-2025-04-20"); + const hk = Object.keys(headers).map((k) => k.toLowerCase()); + assert.ok(!hk.includes("x-api-key")); + + // payload: max_tokens + spoof (3 blocos) + sem effort/thinking (modelo incapaz) + const body = JSON.parse(captured.body); + assert.equal(body.max_tokens, 512); + assert.equal(body.model, "claude-haiku-4-5"); + assert.equal(body.system.length, 3); + assert.match(body.system[0].text, /You are Claude Code/); + assert.equal(body.system[2].text, "Você é o curador."); + assert.equal(body.output_config, undefined); + assert.equal(body.thinking, undefined); +}); + +test("AC5: callSubscription seta effort/thinking e OMITE temperature em modelo capaz", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + let captured: any = null; + const fetchImpl = (async (_url: string, init: any) => { + captured = init; + return { ok: true, status: 200, json: async () => ({ content: [{ type: "text", text: "ok" }] }) }; + }) as unknown as typeof fetch; + const valid: StoredCredential = { + access_token: "sk-ant-oat01-OPUS", + refresh_token: null, + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + await callSubscription( + "acme", + { messages: [{ role: "user", content: "x" }] }, // default model claude-opus-4-8 + { withAccountLock, loadFn: async () => valid, saveFn: async () => {}, fetchImpl, now }, + ); + const body = JSON.parse(captured.body); + assert.equal(body.model, "claude-opus-4-8"); + assert.deepEqual(body.output_config, { effort: "high" }); + assert.deepEqual(body.thinking, { type: "adaptive" }); + assert.equal(body.temperature, undefined, "temperature deve ser OMITIDO com effort"); + assert.equal(body.max_tokens, 2048); // default +}); + +test("AC5: callSubscription 401 -> SubscriptionReauthRequired", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const valid: StoredCredential = { + access_token: "sk-ant-oat01-401", + refresh_token: null, + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + await assert.rejects( + () => + callSubscription( + "acme", + { messages: [{ role: "user", content: "x" }] }, + { + withAccountLock, + loadFn: async () => valid, + saveFn: async () => {}, + fetchImpl: (async () => ({ ok: false, status: 401, text: async () => "unauthorized" })) as unknown as typeof fetch, + now, + }, + ), + (e) => e instanceof SubscriptionReauthRequired, + ); +}); + +test("AC5: callSubscription 429 -> SubscriptionRateLimited", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const valid: StoredCredential = { + access_token: "sk-ant-oat01-429", + refresh_token: null, + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + await assert.rejects( + () => + callSubscription( + "acme", + { messages: [{ role: "user", content: "x" }] }, + { + withAccountLock, + loadFn: async () => valid, + saveFn: async () => {}, + fetchImpl: (async () => ({ ok: false, status: 429, text: async () => "rate limited" })) as unknown as typeof fetch, + now, + }, + ), + (e) => e instanceof SubscriptionRateLimited, + ); +}); + +test("AC5: um erro logado NÃO contém o access_token", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const valid: StoredCredential = { + access_token: "sk-ant-oat01-SECRETLOG", + refresh_token: null, + expires_at: new Date(now.getTime() + 3600_000), + auth_mode: "claude", + }; + const { withAccountLock } = makeSerializingLock(); + const logged: string[] = []; + const origErr = console.error; + const origWarn = console.warn; + console.error = (...a: unknown[]) => logged.push(a.map(String).join(" ")); + console.warn = (...a: unknown[]) => logged.push(a.map(String).join(" ")); + try { + await callSubscription( + "acme", + { messages: [{ role: "user", content: "x" }] }, + { + withAccountLock, + loadFn: async () => valid, + saveFn: async () => {}, + fetchImpl: (async () => ({ ok: false, status: 401, text: async () => "boom" })) as unknown as typeof fetch, + now, + }, + ).catch(() => {}); + } finally { + console.error = origErr; + console.warn = origWarn; + } + const all = logged.join("\n"); + assert.ok(!all.includes("sk-ant-oat01-SECRETLOG"), "log NUNCA pode conter o access token"); +}); diff --git a/src/__tests__/claude-subscription.test.ts b/src/__tests__/claude-subscription.test.ts new file mode 100644 index 0000000..ceecc3c --- /dev/null +++ b/src/__tests__/claude-subscription.test.ts @@ -0,0 +1,292 @@ +// src/__tests__/claude-subscription.test.ts +// Curadoria via assinatura Claude — provider PURO (sem DB, sem rede real). +// Porta o mecanismo do Odysseus (Bearer OAuth + oauth-2025-04-20): parse de +// credencial colada (3 formatos + heurística ms/s), headers OAuth (Bearer, NUNCA +// x-api-key), spoof de identidade Claude Code antes do system do usuário, lógica +// de refresh (skew 300s, bare-token nunca), gate de effort por versão de modelo, +// e refresh via token endpoint (fetch fake). Cobre ACs 1-4 do spec. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + CLIENT_ID, + TOKEN_URL, + MESSAGES_URL, + ANTHROPIC_VERSION, + OAUTH_BETA, + DEFAULT_TOKEN_TTL_DAYS, + CLAUDE_CODE_SYSTEM, + CLAUDE_CODE_GUIDANCE, + parsePastedCredential, + oauthHeaders, + buildSubscriptionSystemBlocks, + needsRefresh, + supportsEffort, + refreshSubscription, +} from "../claude-subscription.js"; + +const DAY_MS = 86_400_000; + +// --- Constantes (centralizadas, fixas) -------------------------------------- + +test("constantes são as fixas do mecanismo Odysseus (nunca de input)", () => { + assert.equal(CLIENT_ID, "9d1c250a-e61b-44d9-88ed-5944d1962f5e"); + assert.equal(TOKEN_URL, "https://platform.claude.com/v1/oauth/token"); + assert.equal(MESSAGES_URL, "https://api.anthropic.com/v1/messages"); + assert.equal(ANTHROPIC_VERSION, "2023-06-01"); + assert.equal(OAUTH_BETA, "oauth-2025-04-20"); + assert.equal(DEFAULT_TOKEN_TTL_DAYS, 365); + assert.equal(CLAUDE_CODE_SYSTEM, "You are Claude Code, Anthropic's official CLI for Claude."); + assert.ok(CLAUDE_CODE_GUIDANCE.length > 0); +}); + +// --- AC1: parsePastedCredential (3 formatos + heurística + bare TTL) -------- + +test("parse (a) bare token sk-ant-oat01 → refresh null, expiry = now + 365d", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const out = parsePastedCredential("sk-ant-oat01-abc123", now); + assert.equal(out.access_token, "sk-ant-oat01-abc123"); + assert.equal(out.refresh_token, null); + assert.equal( + out.expires_at.getTime(), + now.getTime() + DEFAULT_TOKEN_TTL_DAYS * DAY_MS, + ); +}); + +test("parse bare token tolera espaço em volta", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const out = parsePastedCredential(" sk-ant-oat01-xyz ", now); + assert.equal(out.access_token, "sk-ant-oat01-xyz"); + assert.equal(out.refresh_token, null); +}); + +test("parse (b) JSON claudeAiOauth {accessToken,refreshToken,expiresAt}", () => { + const expMs = Date.UTC(2026, 8, 1); // epoch ms (> 1e12) + const text = JSON.stringify({ + claudeAiOauth: { + accessToken: "sk-ant-oat01-J", + refreshToken: "rt-J", + expiresAt: expMs, + }, + }); + const out = parsePastedCredential(text); + assert.equal(out.access_token, "sk-ant-oat01-J"); + assert.equal(out.refresh_token, "rt-J"); + assert.equal(out.expires_at.getTime(), expMs); +}); + +test("parse (c) flat {access_token, refresh_token, expires_at}", () => { + const expMs = Date.UTC(2026, 8, 1); + const text = JSON.stringify({ + access_token: "sk-ant-oat01-F", + refresh_token: "rt-F", + expires_at: expMs, + }); + const out = parsePastedCredential(text); + assert.equal(out.access_token, "sk-ant-oat01-F"); + assert.equal(out.refresh_token, "rt-F"); + assert.equal(out.expires_at.getTime(), expMs); +}); + +test("parse flat aceita expiresAt camelCase também", () => { + const expMs = Date.UTC(2027, 0, 1); + const out = parsePastedCredential( + JSON.stringify({ access_token: "sk-ant-oat01-C", expiresAt: expMs }), + ); + assert.equal(out.access_token, "sk-ant-oat01-C"); + assert.equal(out.refresh_token, null); + assert.equal(out.expires_at.getTime(), expMs); +}); + +test("AC4: heurística ms-vs-s — segundos (<= 1e12) viram ms via *1000", () => { + const expSecs = Math.floor(Date.UTC(2026, 8, 1) / 1000); // epoch SECONDS + const out = parsePastedCredential( + JSON.stringify({ access_token: "sk-ant-oat01-S", expiresAt: expSecs }), + ); + // raw <= 1e12 → tratado como segundos → *1000 + assert.equal(out.expires_at.getTime(), expSecs * 1000); +}); + +test("AC4: heurística ms-vs-s — ms (> 1e12) ficam como estão", () => { + const expMs = Date.UTC(2026, 8, 1); // > 1e12 + const out = parsePastedCredential( + JSON.stringify({ access_token: "sk-ant-oat01-M", expiresAt: expMs }), + ); + assert.equal(out.expires_at.getTime(), expMs); +}); + +test("parse JSON sem expiry aplica TTL default (como bare token)", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const out = parsePastedCredential( + JSON.stringify({ access_token: "sk-ant-oat01-N", refresh_token: "rt-N" }), + now, + ); + assert.equal(out.expires_at.getTime(), now.getTime() + DEFAULT_TOKEN_TTL_DAYS * DAY_MS); +}); + +test("parse lixo (string vazia) → erro claro", () => { + assert.throws(() => parsePastedCredential(" "), /credencial|token|vazi/i); +}); + +test("parse JSON inválido → erro claro", () => { + assert.throws(() => parsePastedCredential("{not json"), /credencial|JSON|token/i); +}); + +test("parse JSON sem access token → erro claro", () => { + assert.throws( + () => parsePastedCredential(JSON.stringify({ refreshToken: "rt-only" })), + /access|token/i, + ); +}); + +// --- AC2: oauthHeaders (Bearer + oauth-beta, NUNCA x-api-key) --------------- + +test("AC2: oauthHeaders produz Bearer + anthropic-beta + version, sem x-api-key", () => { + const h = oauthHeaders("sk-ant-oat01-TOK"); + assert.equal(h["Authorization"], "Bearer sk-ant-oat01-TOK"); + assert.equal(h["anthropic-beta"], OAUTH_BETA); + assert.equal(h["anthropic-version"], ANTHROPIC_VERSION); + // NUNCA x-api-key (case-insensitive check) + const keys = Object.keys(h).map((k) => k.toLowerCase()); + assert.ok(!keys.includes("x-api-key"), "headers nunca devem ter x-api-key"); +}); + +// --- AC3: buildSubscriptionSystemBlocks (spoof primeiro, user depois) ------- + +test("AC3: spoof — identidade primeiro, guidance, depois system do usuário", () => { + const blocks = buildSubscriptionSystemBlocks("Você é o curador de memória."); + assert.equal(blocks.length, 3); + assert.equal(blocks[0].type, "text"); + assert.equal(blocks[0].text, CLAUDE_CODE_SYSTEM); + assert.equal(blocks[1].text, CLAUDE_CODE_GUIDANCE); + assert.equal(blocks[2].text, "Você é o curador de memória."); +}); + +test("AC3: sem system do usuário → apenas os 2 blocos de spoof", () => { + const blocks = buildSubscriptionSystemBlocks(); + assert.equal(blocks.length, 2); + assert.equal(blocks[0].text, CLAUDE_CODE_SYSTEM); + assert.equal(blocks[1].text, CLAUDE_CODE_GUIDANCE); +}); + +test("AC3: system do usuário vazio não vira bloco", () => { + const blocks = buildSubscriptionSystemBlocks(""); + assert.equal(blocks.length, 2); +}); + +// --- AC4: needsRefresh (skew 300s, bare-token nunca) ------------------------ + +test("AC4: needsRefresh true quando expira dentro de 300s e há refresh_token", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const soon = new Date(now.getTime() + 200_000); // 200s < 300s skew + assert.equal(needsRefresh(soon, true, now), true); +}); + +test("AC4: needsRefresh false quando expira além de 300s", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const later = new Date(now.getTime() + 3600_000); // 1h + assert.equal(needsRefresh(later, true, now), false); +}); + +test("AC4: needsRefresh respeita exatamente o skew de 300s (<=)", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const exactly = new Date(now.getTime() + 300_000); + assert.equal(needsRefresh(exactly, true, now), true); // <= now+300s + const oneMore = new Date(now.getTime() + 300_001); + assert.equal(needsRefresh(oneMore, true, now), false); +}); + +test("AC4: bare-token (sem refresh_token) NUNCA precisa refresh, mesmo expirado", () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const past = new Date(now.getTime() - 10_000); + assert.equal(needsRefresh(past, false, now), false); +}); + +test("AC4: needsRefresh com expiresAt null e sem refresh_token → false (não tenta)", () => { + // Sem expiry conhecido e sem refresh, não há o que refrescar. + assert.equal(needsRefresh(null, false), false); +}); + +// --- supportsEffort (Opus >=4.5, Sonnet >=4.6, Fable/Mythos) ---------------- + +test("supportsEffort: Opus 4.5+ sim, Opus 4.4 não", () => { + assert.equal(supportsEffort("claude-opus-4-8"), true); + assert.equal(supportsEffort("claude-opus-4-5"), true); + assert.equal(supportsEffort("claude-opus-4-4"), false); + assert.equal(supportsEffort("claude-opus-4-20250514"), false); // 4.0 datado +}); + +test("supportsEffort: Sonnet 4.6+ sim, 4.5 não", () => { + assert.equal(supportsEffort("claude-sonnet-4-6"), true); + assert.equal(supportsEffort("claude-sonnet-4-5"), false); +}); + +test("supportsEffort: Fable/Mythos por nome", () => { + assert.equal(supportsEffort("claude-fable-1"), true); + assert.equal(supportsEffort("some-mythos-model"), true); +}); + +test("supportsEffort: Haiku / desconhecido → false; input vazio → false", () => { + assert.equal(supportsEffort("claude-haiku-4-5"), false); + assert.equal(supportsEffort("gpt-4o"), false); + assert.equal(supportsEffort(""), false); +}); + +test("supportsEffort: 'octopus-4-8' não é Opus (boundary)", () => { + assert.equal(supportsEffort("octopus-4-8"), false); +}); + +// --- AC4: refreshSubscription (payload + expires_in delta + preserva rt) ---- + +test("AC4: refreshSubscription monta grant_type=refresh_token + client_id e usa expires_in", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + let capturedUrl = ""; + let capturedBody: any = null; + const fetchImpl = (async (url: string, init: any) => { + capturedUrl = url; + capturedBody = JSON.parse(init.body); + return { + ok: true, + status: 200, + json: async () => ({ + access_token: "sk-ant-oat01-NEW", + refresh_token: "rt-NEW", + expires_in: 3600, + }), + }; + }) as unknown as typeof fetch; + + const out = await refreshSubscription("rt-OLD", fetchImpl, now); + assert.equal(capturedUrl, TOKEN_URL); + assert.equal(capturedBody.grant_type, "refresh_token"); + assert.equal(capturedBody.refresh_token, "rt-OLD"); + assert.equal(capturedBody.client_id, CLIENT_ID); + + assert.equal(out.access_token, "sk-ant-oat01-NEW"); + assert.equal(out.refresh_token, "rt-NEW"); + // novo expiry = now + expires_in (delta em segundos) + assert.equal(out.expires_at.getTime(), now.getTime() + 3600 * 1000); +}); + +test("AC4: refreshSubscription preserva refresh_token antigo quando a resposta não traz um", async () => { + const now = new Date("2026-06-19T00:00:00.000Z"); + const fetchImpl = (async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "sk-ant-oat01-NEW2", expires_in: 1800 }), + })) as unknown as typeof fetch; + + const out = await refreshSubscription("rt-PRESERVE", fetchImpl, now); + assert.equal(out.access_token, "sk-ant-oat01-NEW2"); + // sem refresh_token na resposta → null (caller preserva o antigo) + assert.equal(out.refresh_token, null); + assert.equal(out.expires_at.getTime(), now.getTime() + 1800 * 1000); +}); + +test("refreshSubscription propaga falha HTTP (>=400) como erro", async () => { + const fetchImpl = (async () => ({ + ok: false, + status: 401, + json: async () => ({ error: "invalid_grant" }), + })) as unknown as typeof fetch; + await assert.rejects(() => refreshSubscription("rt-bad", fetchImpl), /401|refresh|reconect/i); +}); diff --git a/src/claude-subscription-store.ts b/src/claude-subscription-store.ts new file mode 100644 index 0000000..93f3767 --- /dev/null +++ b/src/claude-subscription-store.ts @@ -0,0 +1,308 @@ +// src/claude-subscription-store.ts +// Storage da credencial de assinatura Claude (vault `account_secrets`, kind +// `claude_oauth`) + a chamada central `callSubscription`. Toda operação é +// account-scoped por parâmetro EXPLÍCITO — NUNCA `getAccountId()` (que faz +// fallback pra 'bruno' fora de request-context e usaria a assinatura da conta +// errada). A conta A só resolve a própria credencial. +// +// O refresh roda numa transação com `pg_advisory_xact_lock` (lock CROSS-PROCESS: +// 3 processos PM2 no mesmo Postgres + rotação de refresh token). Esse lock fica +// atrás de um seam injetável (`deps.withAccountLock`) pra o teste provar a +// serialização sem Postgres real. O token NUNCA é logado. +import pg from "pg"; +import { getPool } from "./rag/storage.js"; +import { setAccountSecret, getAccountSecret, deleteAccountSecret } from "./secrets.js"; +import { + MESSAGES_URL, + oauthHeaders, + buildSubscriptionSystemBlocks, + needsRefresh, + supportsEffort, + refreshSubscription, + type ParsedCredential, +} from "./claude-subscription.js"; + +const VAULT_KIND = "claude_oauth"; + +// --- Erros ------------------------------------------------------------------ + +/** Credencial inválida/expirada além de refresh; precisa reconectar. */ +export class SubscriptionReauthRequired extends Error { + constructor(message = "Assinatura Claude precisa ser reconectada.") { + super(message); + this.name = "SubscriptionReauthRequired"; + } +} + +/** Quota/rate limit upstream; reconectar não resolve. */ +export class SubscriptionRateLimited extends Error { + constructor(message = "Assinatura Claude atingiu o limite de uso. Tente mais tarde.") { + super(message); + this.name = "SubscriptionRateLimited"; + } +} + +/** Nenhuma credencial de assinatura conectada para a conta. */ +export class SubscriptionNotConnected extends Error { + constructor(message = "Nenhuma assinatura Claude conectada para esta conta.") { + super(message); + this.name = "SubscriptionNotConnected"; + } +} + +// --- Tipos ------------------------------------------------------------------ + +export interface StoredCredential { + access_token: string; + refresh_token: string | null; + expires_at: Date; + auth_mode: "claude"; +} + +/** Seam injetável: dentro do callback `fn`, a credencial é lida/escrita SOB o + * lock (re-leitura + re-checagem). O default usa `pg_advisory_xact_lock`. */ +export type WithAccountLock = (accountId: string, fn: () => Promise) => Promise; + +export interface ResolveDeps { + withAccountLock?: WithAccountLock; + loadFn?: (accountId: string) => Promise; + saveFn?: (accountId: string, cred: StoredCredential) => Promise; + fetchImpl?: typeof fetch; + now?: Date; + pool?: Pick; +} + +// --- Vault CRUD ------------------------------------------------------------- + +/** Carrega + decripta a credencial de assinatura da conta, ou null se ausente. */ +export async function loadCredential(accountId: string): Promise { + const raw = await getAccountSecret(accountId, VAULT_KIND); + if (!raw) return null; + const obj = JSON.parse(raw); + return { + access_token: String(obj.access_token ?? ""), + refresh_token: obj.refresh_token ? String(obj.refresh_token) : null, + expires_at: new Date(obj.expires_at), + auth_mode: "claude", + }; +} + +/** Grava (criptografada) a credencial de assinatura da conta. `expires_at` vai + * como ISO. Upsert em (account_id, kind=claude_oauth). */ +export async function saveCredential(accountId: string, cred: StoredCredential): Promise { + const payload = JSON.stringify({ + access_token: cred.access_token, + refresh_token: cred.refresh_token, + expires_at: cred.expires_at.toISOString(), + auth_mode: "claude", + }); + await setAccountSecret(accountId, VAULT_KIND, payload); +} + +/** Esquece a credencial localmente (não revoga no provedor). */ +export async function deleteCredential(accountId: string): Promise { + await deleteAccountSecret(accountId, VAULT_KIND); +} + +/** Vault kind do cursor de extração de memória por conta (timestamp ISO da + * memória de conversa mais recente já processada pelo extrator). */ +const CURSOR_KIND = "memory_extract_cursor"; + +/** + * Lista as contas COM assinatura Claude conectada (DISTINCT account_id em + * account_secrets WHERE kind='claude_oauth'). É a base do agente de auto-curadoria + * (D4/D6): só quem conectou (opt-in) entra no ciclo. NÃO é + * listAccountsWithProfileFacts (essa lista quem tem fatos, não quem tem assinatura). + */ +export async function listAccountsWithSubscription(): Promise { + const pool = getPool(); + const { rows } = await pool.query<{ account_id: string }>( + `SELECT DISTINCT account_id FROM account_secrets WHERE kind = $1`, + [VAULT_KIND], + ); + return rows.map((r) => r.account_id); +} + +/** Lê o cursor de extração (timestamp ISO da última memória processada) da conta, + * ou null se nunca rodou. Account-scoped. */ +export async function loadMemoryExtractCursor(accountId: string): Promise { + return getAccountSecret(accountId, CURSOR_KIND); +} + +/** Grava o cursor de extração (timestamp ISO) da conta. Account-scoped. */ +export async function saveMemoryExtractCursor(accountId: string, cursorIso: string): Promise { + await setAccountSecret(accountId, CURSOR_KIND, cursorIso); +} + +/** Conveniência: grava a credencial recém-parseada (do connect no portal). */ +export async function saveParsedCredential( + accountId: string, + parsed: ParsedCredential, +): Promise { + await saveCredential(accountId, { ...parsed, auth_mode: "claude" }); +} + +// --- Lock cross-process (default) ------------------------------------------- + +/** + * Lock CROSS-PROCESS via `pg_advisory_xact_lock(hashtext('claude_refresh:'||id))` + * dentro de UMA transação: o advisory lock é liberado automaticamente no + * COMMIT/ROLLBACK, serializando o refresh entre os 3 processos PM2 que partilham + * o mesmo Postgres. `fn` roda DENTRO da transação, com a credencial relida sob o + * lock. Um lock in-process (Map) NÃO bastaria. + */ +async function pgAdvisoryLock(accountId: string, fn: () => Promise): Promise { + const pool = getPool() as pg.Pool; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + `claude_refresh:${accountId}`, + ]); + const out = await fn(); + await client.query("COMMIT"); + return out; + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + throw err; + } finally { + client.release(); + } +} + +// --- Resolução do access token (refresh-aware, sob lock) -------------------- + +/** + * Resolve o access token válido da conta: dentro do lock cross-process, (a) relê + * a credencial, (b) re-checa needsRefresh, (c) se preciso refresca (preservando o + * refresh_token antigo quando a resposta não traz um), (d) grava, (e) retorna o + * access_token. Sem credencial → SubscriptionNotConnected. 401 no refresh → + * SubscriptionReauthRequired. + * + * `accountId` é EXPLÍCITO; a conta A nunca lê a credencial de B. + */ +export async function resolveAccessToken(accountId: string, deps: ResolveDeps = {}): Promise { + const withAccountLock = deps.withAccountLock ?? pgAdvisoryLock; + const loadFn = deps.loadFn ?? loadCredential; + const saveFn = deps.saveFn ?? saveCredential; + const fetchImpl = deps.fetchImpl ?? fetch; + const now = deps.now ?? new Date(); + + return withAccountLock(accountId, async () => { + // (a) re-leitura SOB o lock — outro processo pode ter refrescado já. + const cred = await loadFn(accountId); + if (!cred) { + throw new SubscriptionNotConnected(); + } + // (b) re-checagem de expiry. + if (!needsRefresh(cred.expires_at, !!cred.refresh_token, now)) { + return cred.access_token; + } + // (c) refresh. 401/falha → reauth. + let refreshed: ParsedCredential; + try { + refreshed = await refreshSubscription(cred.refresh_token as string, fetchImpl, now); + } catch (err) { + throw new SubscriptionReauthRequired( + err instanceof Error ? err.message : "Falha ao refrescar a assinatura Claude.", + ); + } + // (d) grava — preserva o refresh_token antigo quando a resposta não traz um. + const next: StoredCredential = { + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token ?? cred.refresh_token, + expires_at: refreshed.expires_at, + auth_mode: "claude", + }; + await saveFn(accountId, next); + // (e) retorna. + return next.access_token; + }); +} + +// --- Chamada central -------------------------------------------------------- + +export interface SubscriptionMessage { + role: "user" | "assistant"; + content: unknown; +} + +export interface CallSubscriptionOpts { + system?: string; + messages: SubscriptionMessage[]; + model?: string; + maxTokens?: number; +} + +/** + * Chamada central de inferência via assinatura: resolve o token (refresh-aware, + * sob lock), monta os headers OAuth + payload com o spoof de Claude Code, chama + * `/v1/messages` (URL FIXA). Para modelos capazes (`supportsEffort`) seta + * `output_config.effort:"high"` + `thinking:{type:"adaptive"}` e OMITE + * `temperature` (incompatível). 401→reauth, 429→rate limited. NUNCA loga o token. + */ +export async function callSubscription( + accountId: string, + opts: CallSubscriptionOpts, + deps: ResolveDeps = {}, +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + const model = opts.model ?? "claude-opus-4-8"; + const maxTokens = opts.maxTokens ?? 2048; + + const accessToken = await resolveAccessToken(accountId, deps); + + const payload: Record = { + model, + max_tokens: maxTokens, + system: buildSubscriptionSystemBlocks(opts.system), + messages: opts.messages, + }; + if (supportsEffort(model)) { + // Effort drive o thinking adaptativo; sampling params são incompatíveis → + // NÃO incluir temperature. + payload.output_config = { effort: "high" }; + payload.thinking = { type: "adaptive" }; + } + + let res: { ok: boolean; status: number; json?: () => Promise; text?: () => Promise }; + try { + res = await fetchImpl(MESSAGES_URL, { + method: "POST", + headers: { "Content-Type": "application/json", ...oauthHeaders(accessToken) }, + body: JSON.stringify(payload), + }); + } catch (err) { + // Falha de rede — logamos SEM o header/token, só a mensagem. + console.error( + `[claude-sub] callSubscription network error (account=${accountId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + throw err; + } + + if (res.status === 401) { + throw new SubscriptionReauthRequired(); + } + if (res.status === 429) { + throw new SubscriptionRateLimited(); + } + if (!res.ok) { + // Erro upstream — log SEM token (só status). Não lê o corpo pra log. + console.error(`[claude-sub] callSubscription failed (account=${accountId}, status=${res.status})`); + throw new Error(`Chamada à assinatura Claude falhou (HTTP ${res.status}).`); + } + + const data: any = res.json ? await res.json() : {}; + return extractText(data); +} + +/** Concatena o texto de todos os blocos `type:"text"` da resposta Messages API. */ +function extractText(data: any): string { + const blocks = Array.isArray(data?.content) ? data.content : []; + return blocks + .filter((b: any) => b && b.type === "text" && typeof b.text === "string") + .map((b: any) => b.text) + .join(""); +} diff --git a/src/claude-subscription.ts b/src/claude-subscription.ts new file mode 100644 index 0000000..38cdc60 --- /dev/null +++ b/src/claude-subscription.ts @@ -0,0 +1,246 @@ +// src/claude-subscription.ts +// Provider de assinatura Claude (OAuth de assinatura via Bearer) — porta PURA do +// mecanismo do Odysseus (`src/claude_subscription.py` + `src/llm_core.py`). Zero +// DB, zero estado: só parse de credencial colada, headers OAuth, spoof de +// identidade Claude Code, lógica de refresh e gate de effort. O storage e a +// chamada central vivem em `claude-subscription-store.ts`. +// +// SEGURANÇA: as URLs são FIXAS (platform.claude.com / api.anthropic.com), nunca +// derivadas do paste/body. A autenticação é SEMPRE `Authorization: Bearer` + +// `anthropic-beta: oauth-2025-04-20` — NUNCA `x-api-key` (que é a chave paga da +// API, não a assinatura). Usa `fetch` nativo (zero-dep), não o SDK Anthropic. + +// --- Constantes centralizadas (cobertas por teste) -------------------------- + +/** Client OAuth público do Claude Code CLI / `claude setup-token`. Só usado pra + * refrescar um token colado junto de um refresh_token. */ +export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +/** Endpoint de refresh do token de assinatura. FIXO — nunca do input. */ +export const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; + +/** Endpoint real de inferência (Messages API). FIXO — nunca do input. */ +export const MESSAGES_URL = "https://api.anthropic.com/v1/messages"; + +/** Versão da API Anthropic enviada em todo request. */ +export const ANTHROPIC_VERSION = "2023-06-01"; + +/** Beta header que autoriza um token de assinatura (OAuth bearer) no /v1/messages. */ +export const OAUTH_BETA = "oauth-2025-04-20"; + +/** Tokens colados sem expiry explícito (ex.: `claude setup-token`, ~1 ano) ganham + * este TTL para o resolver nunca tentar refrescar um token não-refrescável. */ +export const DEFAULT_TOKEN_TTL_DAYS = 365; + +/** Primeiro bloco de system obrigatório: sem a identidade do Claude Code, a + * Anthropic dá soft-block 429 em Opus/Sonnet via OAuth de assinatura. */ +export const CLAUDE_CODE_SYSTEM = + "You are Claude Code, Anthropic's official CLI for Claude."; + +/** Segundo bloco: persona/guidance. Espelha o `_CLAUDE_CODE_OAUTH_GUIDANCE` do + * Odysseus (operador sênior, decisivo, alto sinal; usa ferramentas quando há; + * honesto sobre o que falta; responde no idioma do usuário). Vai DEPOIS da + * identidade e ANTES do system do usuário, que ainda tem precedência. */ +export const CLAUDE_CODE_GUIDANCE = + "Operate as a senior operator: decisive, high signal, zero filler. Lead with " + + "the answer, recommendation, or result; detail after, only if it earns its " + + "place. When a question has a defensible best answer, give it and commit, " + + "don't survey options or hedge. Go as deep as the problem demands. Skip " + + "flattery and reflexive caveats; flag a real risk once, plainly. Use only " + + "tools available this turn; if a needed one is absent, say so in one line, " + + "then use what you have. Reply in the user's language. Anything below from " + + "the user, persona, rules, or context overrides this block."; + +const DAY_MS = 86_400_000; +const REFRESH_SKEW_SECONDS = 300; + +// --- Tipos ------------------------------------------------------------------ + +export interface ParsedCredential { + access_token: string; + refresh_token: string | null; + expires_at: Date; +} + +export interface SystemBlock { + type: "text"; + text: string; +} + +// --- AC1: parse da credencial colada ---------------------------------------- + +/** + * Lê uma credencial colada nos 3 formatos do Odysseus: + * (a) bare token (`sk-ant-oat01-…` de `claude setup-token`) → refresh_token=null, + * expires_at = now + DEFAULT_TOKEN_TTL_DAYS; + * (b) JSON do Claude Code: `{"claudeAiOauth":{accessToken,refreshToken,expiresAt}}`; + * (c) flat: `{access_token, refresh_token, expires_at|expiresAt}`. + * + * `expiresAt` é epoch com heurística ms-vs-s (`secs = raw>1e12 ? raw/1000 : raw`, + * fiel a `claude_subscription.py:163`). Sem expiry → aplica o TTL default. Lixo + * (vazio, JSON inválido, sem access token) → lança Error claro. + */ +export function parsePastedCredential(text: string, now: Date = new Date()): ParsedCredential { + const trimmed = (text ?? "").trim(); + if (!trimmed) { + throw new Error("Credencial vazia: cole o token ou o JSON do Claude Code."); + } + + // Formato (b)/(c): JSON. + if (trimmed.startsWith("{")) { + let data: any; + try { + data = JSON.parse(trimmed); + } catch { + throw new Error("Credencial inválida: não é um token nem JSON válido do Claude Code."); + } + const obj = + data && typeof data.claudeAiOauth === "object" && data.claudeAiOauth !== null + ? data.claudeAiOauth + : data; + const access = String(obj?.accessToken ?? obj?.access_token ?? "").trim(); + if (!access) { + throw new Error("Credencial inválida: sem access token."); + } + const refreshRaw = String(obj?.refreshToken ?? obj?.refresh_token ?? "").trim(); + const refresh_token = refreshRaw || null; + + const rawExp = obj?.expiresAt ?? obj?.expires_at; + let expires_at: Date; + if (typeof rawExp === "number" && rawExp > 0) { + // Heurística ms-vs-s: epoch acima de 1e12 já está em ms; abaixo, em segundos. + const secs = rawExp > 1e12 ? rawExp / 1000 : rawExp; + expires_at = new Date(secs * 1000); + } else { + // Sem expiry conhecido → TTL default (igual ao bare token). + expires_at = new Date(now.getTime() + DEFAULT_TOKEN_TTL_DAYS * DAY_MS); + } + return { access_token: access, refresh_token, expires_at }; + } + + // Formato (a): bare access token, sem expiry/refresh. + return { + access_token: trimmed, + refresh_token: null, + expires_at: new Date(now.getTime() + DEFAULT_TOKEN_TTL_DAYS * DAY_MS), + }; +} + +// --- AC2: headers OAuth (Bearer, NUNCA x-api-key) --------------------------- + +/** Headers de um request OAuth-bearer Anthropic. NUNCA `x-api-key`. */ +export function oauthHeaders(accessToken: string): Record { + return { + "anthropic-version": ANTHROPIC_VERSION, + "anthropic-beta": OAUTH_BETA, + Authorization: `Bearer ${accessToken}`, + }; +} + +// --- AC3: spoof de identidade Claude Code ----------------------------------- + +/** + * Monta os blocos de `system` para um request de assinatura: SEMPRE o bloco de + * identidade do Claude Code primeiro, depois a guidance, e só então o system do + * usuário (que ainda tem precedência). Sem a identidade, a Anthropic dá 429 em + * Opus/Sonnet via OAuth de assinatura. + */ +export function buildSubscriptionSystemBlocks(userSystem?: string): SystemBlock[] { + const blocks: SystemBlock[] = [ + { type: "text", text: CLAUDE_CODE_SYSTEM }, + { type: "text", text: CLAUDE_CODE_GUIDANCE }, + ]; + if (userSystem && userSystem.trim()) { + blocks.push({ type: "text", text: userSystem }); + } + return blocks; +} + +// --- AC4: lógica de refresh ------------------------------------------------- + +/** + * True só quando HÁ refresh_token E o token expira dentro do skew (300s). Um bare + * token (sem refresh_token) NUNCA precisa refresh — é usado até expirar, então é + * sempre `false` (espelha `claude_subscription.py:273`). Sem essa guarda, o + * resolver tentaria refrescar um token não-refrescável. + */ +export function needsRefresh( + expiresAt: Date | null, + hasRefreshToken: boolean, + now: Date = new Date(), +): boolean { + if (!hasRefreshToken) return false; + if (!expiresAt) return false; + return expiresAt.getTime() <= now.getTime() + REFRESH_SKEW_SECONDS * 1000; +} + +/** Modelos que aceitam `output_config.effort`: Opus ≥4.5, Sonnet ≥4.6, + * Fable/Mythos. Espelha `_anthropic_supports_effort` do Odysseus. */ +export function supportsEffort(model: string): boolean { + if (!model) return false; + const m = model.toLowerCase(); + if (m.includes("fable") || m.includes("mythos")) return true; + // `(? 4 || (major === 4 && minor >= 5); + } + const sm = m.match(/(? 4 || (major === 4 && minor >= 6); + } + return false; +} + +/** + * POST no token endpoint para refrescar o access token. Resposta traz `expires_in` + * (delta em SEGUNDOS), NÃO um expiry absoluto → novo `expires_at = now + + * expires_in`. `refresh_token` é o novo da resposta, ou `null` quando a resposta + * não traz um (o caller preserva o antigo). Falha HTTP (>=400) → lança Error. + * + * `fetchImpl` é injetável pra teste (default = `fetch` nativo). + */ +export async function refreshSubscription( + refreshToken: string, + fetchImpl: typeof fetch = fetch, + now: Date = new Date(), +): Promise { + if (!refreshToken) { + throw new Error("Sem refresh token: reconecte a assinatura com um token novo."); + } + const res = await fetchImpl(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + if (!res.ok) { + // Não logamos o corpo (pode ecoar tokens); só o status, fail-closed. + throw new Error(`Refresh da assinatura Claude falhou (HTTP ${res.status}). Reconecte.`); + } + const data: any = await res.json(); + const access = String(data?.access_token ?? "").trim(); + if (!access) { + throw new Error("Refresh da assinatura Claude não retornou access token. Reconecte."); + } + const newRefresh = String(data?.refresh_token ?? "").trim(); + const expiresIn = data?.expires_in; + const expires_at = + typeof expiresIn === "number" && expiresIn > 0 + ? new Date(now.getTime() + expiresIn * 1000) + : new Date(now.getTime() + DEFAULT_TOKEN_TTL_DAYS * DAY_MS); + return { + access_token: access, + refresh_token: newRefresh || null, + expires_at, + }; +} diff --git a/src/index-classifier.ts b/src/index-classifier.ts index 04f5d69..18dda0e 100644 --- a/src/index-classifier.ts +++ b/src/index-classifier.ts @@ -10,6 +10,7 @@ import { syncGranolasToReunioes } from "./classifier/granola-to-reuniao.js"; import { runDailyBriefing } from "./briefing/daily-briefing.js"; import { recordRun } from "./rag/storage.js"; import { tickMemoryCuration } from "./rag/memory-curation-tick.js"; +import { tickMemoryExtraction } from "./rag/memory-extraction-tick.js"; import { runResyncTick } from "./billing/resync-cron.js"; import { notify } from "./notify.js"; @@ -18,6 +19,7 @@ const REVISITAR_CRON = process.env.REVISITAR_CRON ?? "0 7 * * *"; // 07:00 ev const GRANOLA_REUNIAO_CRON = process.env.GRANOLA_REUNIAO_CRON ?? "*/15 * * * *"; // every 15min const BRIEFING_CRON = process.env.BRIEFING_CRON ?? "0 7 * * *"; // 07:00 every day const MEMORY_CURATION_CRON = process.env.MEMORY_CURATION_CRON ?? "15 4 * * *"; // 04:15 every day +const MEMORY_EXTRACT_CRON = process.env.MEMORY_EXTRACT_CRON ?? "45 4 * * *"; // 04:45 every day (após a curadoria) async function tickClassifier(label: string): Promise { const start = Date.now(); @@ -86,7 +88,7 @@ async function tickBriefing(label: string): Promise { } console.log( - `brain-classifier starting; classifier cron: ${CLASSIFIER_CRON}; revisitar cron: ${REVISITAR_CRON}; granola->reuniao cron: ${GRANOLA_REUNIAO_CRON}; briefing cron: ${BRIEFING_CRON}; memory-curation cron: ${MEMORY_CURATION_CRON}`, + `brain-classifier starting; classifier cron: ${CLASSIFIER_CRON}; revisitar cron: ${REVISITAR_CRON}; granola->reuniao cron: ${GRANOLA_REUNIAO_CRON}; briefing cron: ${BRIEFING_CRON}; memory-curation cron: ${MEMORY_CURATION_CRON}; memory-extract cron: ${MEMORY_EXTRACT_CRON}`, ); console.log("running initial classifier tick..."); void tickClassifier("initial"); @@ -143,6 +145,16 @@ cron.schedule(MEMORY_CURATION_CRON, () => { void tickMemoryCuration("cron"); }); +// Spec 2026-06-19 (D4/D6) — agenda o extrator de memória powered pela assinatura +// Claude da conta num horário noturno (default 04:45, DEPOIS da curadoria 04:15). +// O gate MEMORY_EXTRACT_ENABLED é checado DENTRO de tickMemoryExtraction: o +// schedule existe sempre, mas o tick é no-op (não gasta a assinatura de ninguém) +// quando a flag não é 'true'. Lazy-import e recordRun(source='memory-extraction') +// vivem no tick. Erros são engolidos no tick, nunca derrubam o processo. +cron.schedule(MEMORY_EXTRACT_CRON, () => { + void tickMemoryExtraction("cron"); +}); + // Fase 3 billing — per-account auto re-sync. Hourly tick; each account is // re-indexed only when its plan's syncIntervalHours has elapsed (free skipped). const RESYNC_CRON = process.env.RESYNC_CRON ?? "15 * * * *"; diff --git a/src/portal/__tests__/claude-subscription-routes.test.ts b/src/portal/__tests__/claude-subscription-routes.test.ts new file mode 100644 index 0000000..55b03fb --- /dev/null +++ b/src/portal/__tests__/claude-subscription-routes.test.ts @@ -0,0 +1,240 @@ +// src/portal/__tests__/claude-subscription-routes.test.ts +// Rotas /portal/claude/{connect,disconnect,status} (conexão da assinatura Claude +// por conta — spec D7/AC9): exigem sessão, escopadas na conta da sessão +// (res.locals.accountId, NUNCA body/query). connect valida string não-vazia + +// teto de 16 KB ANTES de parsear; salva via saveParsedCredential e NUNCA ecoa o +// token; status reporta connected/expires_at/needs_reauth sem expor access/refresh +// token; disconnect chama deleteCredential com o accountId da sessão. +// +// Sem Postgres real: um pool em memória sobre `account_secrets` (kind +// `claude_oauth`) + a resolução de sessão que requireSession faz. As funções do +// store (loadCredential/saveParsedCredential/deleteCredential) passam por +// setAccountSecret/getAccountSecret/deleteAccountSecret → getPool(), então o +// store roda de verdade (AES-GCM in/out) e as asserções são ponta a ponta. +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import type { Server } from "node:http"; + +// Vault precisa de chave antes de secrets.ts ser exercitado. +process.env.SECRETS_KEY = "0".repeat(64); + +import { createPortalRouter } from "../routes.js"; +import { __setPoolForTest } from "../../rag/storage.js"; +import { hashSession } from "../session.js"; + +const SID = "test-session-claude"; +const ACCOUNT = "acct_claude"; + +// Tokens fake que NUNCA podem aparecer numa resposta HTTP. +const FAKE_ACCESS = "sk-ant-oat01-FAKE-ACCESS-TOKEN-do-not-leak"; +const FAKE_REFRESH = "sk-ant-ort01-FAKE-REFRESH-TOKEN-do-not-leak"; + +let store: Map; // `${account}|${kind}` -> enc_value + +function memPool() { + return { + query: async (sql: string, params: any[] = []) => { + // requireSession: resolve a conta a partir do hash da sessão. + if (/SELECT account_id FROM portal_sessions/i.test(sql)) { + return params[0] === hashSession(SID) + ? { rows: [{ account_id: ACCOUNT }] } + : { rows: [] }; + } + if (/UPDATE portal_sessions/i.test(sql)) return { rows: [], rowCount: 1 }; + + // Vault account_secrets. + if (/INSERT INTO account_secrets/i.test(sql)) { + store.set(`${params[0]}|${params[1]}`, params[2]); + return { rows: [], rowCount: 1 }; + } + if (/SELECT enc_value FROM account_secrets/i.test(sql)) { + const v = store.get(`${params[0]}|${params[1]}`); + return { rows: v ? [{ enc_value: v }] : [] }; + } + if (/DELETE FROM account_secrets/i.test(sql)) { + store.delete(`${params[0]}|${params[1]}`); + return { rows: [], rowCount: 1 }; + } + return { rows: [] }; + }, + }; +} + +let server: Server; +let base = ""; + +before(async () => { + const app = express(); + app.use(express.json()); + app.use(createPortalRouter()); + await new Promise((resolve) => { + server = app.listen(0, () => { + const addr = server.address(); + base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + resolve(); + }); + }); +}); + +after(() => { + server?.close(); + __setPoolForTest(null); +}); + +beforeEach(() => { + store = new Map(); + __setPoolForTest(memPool() as never); +}); + +const cookie = { cookie: `portal_session=${SID}` }; +const jsonHeaders = { ...cookie, "content-type": "application/json" }; + +/** Grava direto no vault uma credencial JSON do Claude Code (como o store faria), + * pra montar o estado "conectado" sem passar pelo connect. */ +async function seedCredential(opts: { expiresAt?: number; refresh?: string | null }) { + const { setAccountSecret } = await import("../../secrets.js"); + const payload = JSON.stringify({ + access_token: FAKE_ACCESS, + refresh_token: opts.refresh === undefined ? FAKE_REFRESH : opts.refresh, + expires_at: new Date(opts.expiresAt ?? Date.now() + 86_400_000).toISOString(), + auth_mode: "claude", + }); + await setAccountSecret(ACCOUNT, "claude_oauth", payload); +} + +// --- auth ------------------------------------------------------------------- +test("sem sessão → 401 nas rotas de assinatura Claude", async () => { + for (const [method, path] of [ + ["POST", "/portal/claude/connect"], + ["POST", "/portal/claude/disconnect"], + ["GET", "/portal/claude/status"], + ] as const) { + const res = await fetch(`${base}${path}`, { method }); + assert.equal(res.status, 401, `${method} ${path}`); + } +}); + +// --- connect: validação ----------------------------------------------------- +test("connect rejeita body sem string (sem parsear/salvar) → 400", async () => { + for (const body of [{}, { credential: 123 }, { credential: null }, { credential: " " }]) { + const res = await fetch(`${base}/portal/claude/connect`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify(body), + }); + assert.equal(res.status, 400, JSON.stringify(body)); + assert.equal(store.size, 0, "nada gravado em body inválido"); + } +}); + +test("connect rejeita credential > 16 KB (sem parsear/salvar)", async () => { + const huge = "x".repeat(16 * 1024 + 1); + const res = await fetch(`${base}/portal/claude/connect`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ credential: huge }), + }); + assert.ok(res.status === 413 || res.status === 400, `status foi ${res.status}`); + assert.equal(store.size, 0, "credencial gigante não pode ser gravada"); +}); + +// --- connect: sucesso ------------------------------------------------------- +test("connect com credencial válida salva na conta da sessão e não ecoa o token", async () => { + const credential = JSON.stringify({ + claudeAiOauth: { + accessToken: FAKE_ACCESS, + refreshToken: FAKE_REFRESH, + expiresAt: Date.now() + 86_400_000, + }, + }); + const res = await fetch(`${base}/portal/claude/connect`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ credential }), + }); + assert.equal(res.status, 200); + const raw = await res.text(); + const body = JSON.parse(raw); + assert.equal(body.ok, true); + assert.ok(body.expires_at, "resposta traz expires_at"); + + // Salvou na conta da SESSÃO (res.locals.accountId), não num account do body. + const enc = store.get(`${ACCOUNT}|claude_oauth`); + assert.ok(enc, "credencial gravada na conta da sessão"); + assert.match(enc!, /^v1:/, "criptografada em repouso (envelope AES-GCM)"); + assert.ok(!enc!.includes(FAKE_ACCESS), "token não fica em claro no vault"); + + // A resposta NUNCA contém o token. + assert.ok(!raw.includes(FAKE_ACCESS), "resposta não pode conter o access token"); + assert.ok(!raw.includes(FAKE_REFRESH), "resposta não pode conter o refresh token"); +}); + +test("connect com credencial inválida → 400 invalid_credential (nada gravado)", async () => { + const res = await fetch(`${base}/portal/claude/connect`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ credential: "{not valid json" }), + }); + assert.equal(res.status, 400); + assert.equal((await res.json()).error, "invalid_credential"); + assert.equal(store.size, 0); +}); + +test("connect ignora account do body — usa SEMPRE a sessão", async () => { + const credential = `${FAKE_ACCESS}`; // bare token + const res = await fetch(`${base}/portal/claude/connect`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ credential, account: "acct_OUTRA", accountId: "acct_OUTRA" }), + }); + assert.equal(res.status, 200); + assert.ok(store.get(`${ACCOUNT}|claude_oauth`), "gravado na conta da sessão"); + assert.equal(store.get(`acct_OUTRA|claude_oauth`), undefined, "nunca na conta do body"); +}); + +// --- status ----------------------------------------------------------------- +test("status sem credencial → {connected:false}", async () => { + const res = await fetch(`${base}/portal/claude/status`, { headers: cookie }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.connected, false); +}); + +test("status de conta conectada não inclui token na resposta", async () => { + await seedCredential({ expiresAt: Date.now() + 86_400_000 }); + const res = await fetch(`${base}/portal/claude/status`, { headers: cookie }); + assert.equal(res.status, 200); + const raw = await res.text(); + const body = JSON.parse(raw); + assert.equal(body.connected, true); + assert.ok(body.expires_at, "status traz expires_at"); + assert.equal(body.needs_reauth, false, "conectado e válido → não precisa reauth"); + // O ponto crítico: o token NUNCA vaza no status. + assert.ok(!raw.includes(FAKE_ACCESS), "status não pode conter o access token"); + assert.ok(!raw.includes(FAKE_REFRESH), "status não pode conter o refresh token"); + assert.equal(body.access_token, undefined); + assert.equal(body.refresh_token, undefined); +}); + +test("status: conectado, expirado e sem refresh_token → needs_reauth", async () => { + await seedCredential({ expiresAt: Date.now() - 1000, refresh: null }); + const res = await fetch(`${base}/portal/claude/status`, { headers: cookie }); + const body = await res.json(); + assert.equal(body.connected, true); + assert.equal(body.needs_reauth, true, "expirado sem refresh → reconectar"); +}); + +// --- disconnect ------------------------------------------------------------- +test("disconnect chama deleteCredential com o accountId da sessão → 200 {ok:true}", async () => { + await seedCredential({}); + assert.ok(store.get(`${ACCOUNT}|claude_oauth`), "pré-condição: conectado"); + + const res = await fetch(`${base}/portal/claude/disconnect`, { + method: "POST", + headers: cookie, + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + assert.equal(store.get(`${ACCOUNT}|claude_oauth`), undefined, "credencial da sessão removida"); +}); diff --git a/src/portal/routes.ts b/src/portal/routes.ts index 2fe9ef5..fb2fdbb 100644 --- a/src/portal/routes.ts +++ b/src/portal/routes.ts @@ -581,6 +581,83 @@ export function createPortalRouter(): express.Router { res.sendStatus(204); }); + // Conexão Claude (assinatura via paste — spec D7) -------------------------- + // Opt-in, per-account: a pessoa roda `claude setup-token` localmente e cola a + // credencial. Account SEMPRE de res.locals.accountId (sessão), nunca body/query + // — a conta A só gerencia a própria. O token NUNCA é ecoado/logado: connect só + // devolve {ok, expires_at}; status só {connected, expires_at?, needs_reauth?}. + + // Teto de tamanho do paste: rejeita antes de parsear (uma credencial real tem + // poucos KB; >16 KB é abuso/erro de cópia). + const CLAUDE_CREDENTIAL_MAX_BYTES = 16 * 1024; + + router.post("/portal/claude/connect", requireSession, async (req, res) => { + const accountId: string = res.locals.accountId; + const credential = typeof req.body?.credential === "string" ? req.body.credential.trim() : ""; + if (!credential) { + res.status(400).json({ error: "credential obrigatória" }); + return; + } + // Teto de tamanho ANTES de parsear (byte length, não code points). + if (Buffer.byteLength(credential, "utf8") > CLAUDE_CREDENTIAL_MAX_BYTES) { + res.status(413).json({ error: "credential muito grande" }); + return; + } + let parsed; + try { + const { parsePastedCredential } = await import("../claude-subscription.js"); + parsed = parsePastedCredential(credential); + } catch { + // NUNCA ecoa o token nem a mensagem crua do parser (pode conter o paste). + res.status(400).json({ error: "invalid_credential" }); + return; + } + try { + const { saveParsedCredential } = await import("../claude-subscription-store.js"); + await saveParsedCredential(accountId, parsed); + res.json({ ok: true, expires_at: parsed.expires_at.toISOString() }); + } catch (err: any) { + console.error(`[portal] claude/connect ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + + router.post("/portal/claude/disconnect", requireSession, async (_req, res) => { + const accountId: string = res.locals.accountId; + try { + const { deleteCredential } = await import("../claude-subscription-store.js"); + await deleteCredential(accountId); + res.json({ ok: true }); + } catch (err: any) { + console.error(`[portal] claude/disconnect ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + + router.get("/portal/claude/status", requireSession, async (_req, res) => { + const accountId: string = res.locals.accountId; + try { + const { loadCredential } = await import("../claude-subscription-store.js"); + const cred = await loadCredential(accountId); + if (!cred) { + res.json({ connected: false }); + return; + } + // needs_reauth = conectado mas expirado e SEM refresh_token (não dá pra + // refrescar lazy → a pessoa precisa reconectar). NUNCA inclui o token. + const expired = cred.expires_at.getTime() <= Date.now(); + const needsReauth = expired && !cred.refresh_token; + res.json({ + connected: true, + expires_at: cred.expires_at.toISOString(), + needs_reauth: needsReauth, + }); + } catch (err: any) { + console.error(`[portal] claude/status ${accountId}: ${err?.message ?? err}`); + res.status(500).json({ error: "server_error" }); + } + }); + // Google Calendar (multi-conta OAuth) -------------------------------------- router.get("/portal/google/connect", requireSession, (_req, res) => { if (!process.env.GOOGLE_OAUTH_CLIENT_ID || !process.env.GOOGLE_OAUTH_CLIENT_SECRET) { diff --git a/src/rag/__tests__/memory-extraction-tick.test.ts b/src/rag/__tests__/memory-extraction-tick.test.ts new file mode 100644 index 0000000..9510bbe --- /dev/null +++ b/src/rag/__tests__/memory-extraction-tick.test.ts @@ -0,0 +1,60 @@ +// src/rag/__tests__/memory-extraction-tick.test.ts +// AC8 (spec 2026-06-19-curadoria-assinatura-claude-design) — gating do tick do +// extrator de memória no brain-classifier. +// +// tickMemoryExtraction vive num módulo próprio (memory-extraction-tick.ts) com um +// seam injetável (run) para testar o gate sem DB, sem rede, sem assinatura — e sem +// arrastar a cadeia de imports do entrypoint. index-classifier.ts só importa e +// agenda esse tick. Espelha memory-curation-tick.ts. +// +// off (MEMORY_EXTRACT_ENABLED != 'true') -> NÃO chama run (não chama callSubscription). +// on (=== 'true') -> chama run exatamente uma vez. +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { tickMemoryExtraction } from "../memory-extraction-tick.js"; + +const saved = process.env.MEMORY_EXTRACT_ENABLED; + +afterEach(() => { + if (saved === undefined) delete process.env.MEMORY_EXTRACT_ENABLED; + else process.env.MEMORY_EXTRACT_ENABLED = saved; +}); + +test("gate OFF: tickMemoryExtraction não chama run", async () => { + delete process.env.MEMORY_EXTRACT_ENABLED; + let calls = 0; + await tickMemoryExtraction("test", async () => { + calls++; + return { accounts: 0, extracted: 0, signals: 0 }; + }); + assert.equal(calls, 0, "com a flag desligada o extrator não roda (não gasta a assinatura de ninguém)"); +}); + +test("gate OFF explícito ('false'): tickMemoryExtraction não chama run", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "false"; + let calls = 0; + await tickMemoryExtraction("test", async () => { + calls++; + return { accounts: 0, extracted: 0, signals: 0 }; + }); + assert.equal(calls, 0); +}); + +test("gate ON ('true'): tickMemoryExtraction chama run uma vez", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + let calls = 0; + await tickMemoryExtraction("test", async () => { + calls++; + return { accounts: 2, extracted: 5, signals: 4 }; + }); + assert.equal(calls, 1, "com a flag ligada o extrator roda exatamente uma vez"); +}); + +test("gate ON: erro no run é engolido (tick nunca propaga)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + await assert.doesNotReject(async () => { + await tickMemoryExtraction("test", async () => { + throw new Error("boom"); + }); + }); +}); diff --git a/src/rag/__tests__/memory-extractor.test.ts b/src/rag/__tests__/memory-extractor.test.ts new file mode 100644 index 0000000..ecd568d --- /dev/null +++ b/src/rag/__tests__/memory-extractor.test.ts @@ -0,0 +1,380 @@ +// src/rag/__tests__/memory-extractor.test.ts +// D4/D5/D6 (spec 2026-06-19-curadoria-assinatura-claude-design) — extrator de +// memória powered pela assinatura Claude da conta. Tudo com deps injetadas: sem +// rede, sem DB, sem Postgres real. +// +// Cobre (AC 6, 7, 8): +// 6. dado memórias fake + callSubscription fake que devolve fatos, gera SINAIS +// (status='signal', source='llm', account_id certo) em user_profile_facts; +// grava memory_audit trigger='llm' com evidence_ref = PONTEIRO (não conteúdo); +// roda cada conta dentro de requestContext.run (getAccountId() == accountId); +// content com segredo (sk-ant-…) é descartado/redigido e não vira sinal nem +// vai pro evidence_ref; o cursor avança e não reprocessa. +// 7. isolamento: processando a conta A, nunca lê a credencial nem escreve fato +// com account_id de B; conta sem assinatura é pulada (não está na lista). +// 8. gating: coberto no teste do tick (memory-extraction-tick.test.ts). +import { test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { getAccountId } from "../../context.js"; +import { runMemoryExtraction, type MemoryExtractionDeps } from "../memory-extractor.js"; + +const savedEnv = { + MEMORY_EXTRACT_ENABLED: process.env.MEMORY_EXTRACT_ENABLED, + MEMORY_EXTRACT_BUDGET: process.env.MEMORY_EXTRACT_BUDGET, +}; + +afterEach(() => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +interface FakeMemory { + source_id: string; + text: string; + ts: Date; +} + +interface Captured { + upserts: Array>[0]>; + audits: Array>[0]>; + cursorWrites: Array<{ accountId: string; cursor: string }>; + credLoads: string[]; + contextSeen: string[]; +} + +/** Build the dep bundle with sane fakes; per-account memories keyed by account. */ +function makeDeps(opts: { + accounts: string[]; + memoriesByAccount: Record; + llmResponse: (accountId: string, memories: FakeMemory[]) => string; + cursorByAccount?: Record; + capture: Captured; +}): MemoryExtractionDeps { + const { accounts, memoriesByAccount, llmResponse, cursorByAccount = {}, capture } = opts; + return { + listAccountsWithSubscription: async () => accounts, + loadCredential: async (accountId) => { + capture.credLoads.push(accountId); + return { access_token: `tok-${accountId}`, refresh_token: null, expires_at: new Date(), auth_mode: "claude" }; + }, + loadCursor: async (accountId) => cursorByAccount[accountId] ?? null, + saveCursor: async (accountId, cursor) => { + capture.cursorWrites.push({ accountId, cursor }); + }, + loadRecentMemories: async (accountId) => memoriesByAccount[accountId] ?? [], + callSubscription: async (accountId, _opts) => { + // PROVA do requestContext.run: getAccountId() deve ser o accountId da conta. + capture.contextSeen.push(getAccountId()); + return llmResponse(accountId, memoriesByAccount[accountId] ?? []); + }, + upsertFact: async (fact) => { + capture.upserts.push(fact); + return capture.upserts.length; + }, + insertAudit: async (row) => { + capture.audits.push(row); + }, + }; +} + +function emptyCapture(): Captured { + return { upserts: [], audits: [], cursorWrites: [], credLoads: [], contextSeen: [] }; +} + +// --------------------------------------------------------------------------- +// AC6 — extração gera sinais +// --------------------------------------------------------------------------- + +test("runMemoryExtraction gera SINAIS (status=signal, source=llm) com o account_id certo", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { + "friend:a": [{ source_id: "conv-1", text: "Bruno trabalha na Zinom.", ts: new Date("2026-06-10T10:00:00Z") }], + }, + llmResponse: () => + JSON.stringify([ + { category: "trabalho", content: "Trabalha na Zinom." }, + { category: "projeto", content: "Toca o projeto do segundo cérebro." }, + ]), + capture, + }); + + const stats = await runMemoryExtraction(deps); + + assert.equal(stats.accounts, 1); + assert.equal(stats.extracted, 2); + assert.equal(stats.signals, 2); + assert.equal(capture.upserts.length, 2); + for (const u of capture.upserts) { + assert.equal(u.status, "signal"); + assert.equal(u.source, "llm"); + assert.equal(u.account_id, "friend:a"); + assert.equal(u.pinned, false); + assert.ok(u.content_hash && u.content_hash.length > 0, "content_hash deve ser preenchido"); + } +}); + +test("runMemoryExtraction grava memory_audit trigger=llm com evidence_ref = ponteiro (não conteúdo)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { + "friend:a": [{ source_id: "conv-42", text: "uma conversa qualquer sobre rotina", ts: new Date("2026-06-10T10:00:00Z") }], + }, + llmResponse: () => JSON.stringify([{ category: "rotina", content: "Acorda cedo." }]), + capture, + }); + + await runMemoryExtraction(deps); + + assert.equal(capture.audits.length, 1); + const a = capture.audits[0]; + assert.equal(a.account_id, "friend:a"); + assert.equal(a.trigger, "llm"); + assert.equal(a.to_state, "signal"); + assert.equal(a.from_state, null); + // evidence_ref é PONTEIRO (source_id), nunca o conteúdo cru do fato/memória. + assert.equal(a.evidence_ref, "conv-42"); + assert.doesNotMatch(a.evidence_ref ?? "", /Acorda cedo|conversa qualquer/); +}); + +test("runMemoryExtraction roda cada conta dentro do requestContext.run (getAccountId == accountId)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:0fde", "bruno"], + memoriesByAccount: { + "friend:0fde": [{ source_id: "c1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }], + bruno: [{ source_id: "c2", text: "y", ts: new Date("2026-06-11T10:00:00Z") }], + }, + llmResponse: () => JSON.stringify([{ category: "g", content: "fato genérico" }]), + capture, + }); + + await runMemoryExtraction(deps); + + // callSubscription roda DENTRO do requestContext.run da conta correta. + assert.deepEqual(capture.contextSeen, ["friend:0fde", "bruno"]); +}); + +test("runMemoryExtraction descarta fato com segredo (looksLikeSecret) — não vira sinal", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { + "friend:a": [{ source_id: "c1", text: "vazou um token", ts: new Date("2026-06-10T10:00:00Z") }], + }, + llmResponse: () => + JSON.stringify([ + { category: "seg", content: "A chave é sk-ant-oat01-AAAABBBBCCCCDDDDEEEEFFFFGGGG." }, + { category: "trabalho", content: "Trabalha na Zinom." }, + ]), + capture, + }); + + const stats = await runMemoryExtraction(deps); + + // O fato com segredo é descartado inteiro; só o fato limpo vira sinal. + assert.equal(stats.signals, 1); + assert.equal(capture.upserts.length, 1); + assert.equal(capture.upserts[0].content, "Trabalha na Zinom."); + for (const u of capture.upserts) { + assert.doesNotMatch(u.content, /sk-ant-/); + } + // E nada do segredo escapa pro audit. + for (const a of capture.audits) { + assert.doesNotMatch(a.evidence_ref ?? "", /sk-ant-/); + } +}); + +test("runMemoryExtraction redige segredo embutido no meio do content (stripSecrets)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + // Um token NO MEIO de prosa: looksLikeSecret pega (>=32 chars run), então é + // descartado. Garantimos que nem o cru nem o redigido com o segredo vaza. + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { "friend:a": [{ source_id: "c1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }] }, + llmResponse: () => + JSON.stringify([{ category: "x", content: "Usa a chave AKIAIOSFODNN7EXAMPLE no deploy." }]), + capture, + }); + + await runMemoryExtraction(deps); + + for (const u of capture.upserts) { + assert.doesNotMatch(u.content, /AKIAIOSFODNN7EXAMPLE/, "segredo não pode persistir cru"); + } +}); + +test("runMemoryExtraction avança o cursor para a memória mais recente processada", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const latest = new Date("2026-06-12T08:30:00Z"); + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { + "friend:a": [ + { source_id: "c1", text: "a", ts: new Date("2026-06-10T10:00:00Z") }, + { source_id: "c2", text: "b", ts: latest }, + { source_id: "c3", text: "c", ts: new Date("2026-06-11T10:00:00Z") }, + ], + }, + llmResponse: () => JSON.stringify([{ category: "g", content: "fato" }]), + capture, + }); + + await runMemoryExtraction(deps); + + assert.equal(capture.cursorWrites.length, 1); + assert.equal(capture.cursorWrites[0].accountId, "friend:a"); + assert.equal(capture.cursorWrites[0].cursor, latest.toISOString()); +}); + +test("runMemoryExtraction com zero memórias não chama o LLM, não escreve cursor", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + let llmCalls = 0; + const deps: MemoryExtractionDeps = { + ...makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { "friend:a": [] }, + llmResponse: () => "[]", + capture, + }), + callSubscription: async () => { + llmCalls++; + return "[]"; + }, + }; + + const stats = await runMemoryExtraction(deps); + + assert.equal(llmCalls, 0, "sem memórias, não chama a assinatura (poupa custo da pessoa)"); + assert.equal(stats.signals, 0); + assert.equal(capture.cursorWrites.length, 0); +}); + +test("runMemoryExtraction tolera code fences ```json no retorno do LLM", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:a"], + memoriesByAccount: { "friend:a": [{ source_id: "c1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }] }, + llmResponse: () => "```json\n[{\"category\":\"g\",\"content\":\"fato\"}]\n```", + capture, + }); + + const stats = await runMemoryExtraction(deps); + assert.equal(stats.signals, 1); +}); + +test("runMemoryExtraction pula a conta quando o LLM devolve algo não-parseável (não derruba as outras)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["friend:bad", "friend:good"], + memoriesByAccount: { + "friend:bad": [{ source_id: "b1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }], + "friend:good": [{ source_id: "g1", text: "y", ts: new Date("2026-06-11T10:00:00Z") }], + }, + llmResponse: (accountId) => + accountId === "friend:bad" ? "isso não é json" : JSON.stringify([{ category: "g", content: "fato" }]), + capture, + }); + + const stats = await runMemoryExtraction(deps); + + // A conta boa ainda processa. + assert.equal(stats.signals, 1); + assert.equal(capture.upserts.length, 1); + assert.equal(capture.upserts[0].account_id, "friend:good"); + // A conta ruim não escreve cursor (nada processado com sucesso). + assert.ok(!capture.cursorWrites.some((c) => c.accountId === "friend:bad")); +}); + +test("runMemoryExtraction: callSubscription que LANÇA (reauth/rate-limit) pula a conta, segue as outras", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps: MemoryExtractionDeps = { + ...makeDeps({ + accounts: ["friend:err", "friend:ok"], + memoriesByAccount: { + "friend:err": [{ source_id: "e1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }], + "friend:ok": [{ source_id: "o1", text: "y", ts: new Date("2026-06-11T10:00:00Z") }], + }, + llmResponse: () => JSON.stringify([{ category: "g", content: "fato" }]), + capture, + }), + callSubscription: async (accountId) => { + capture.contextSeen.push(getAccountId()); + if (accountId === "friend:err") throw new Error("SubscriptionRateLimited"); + return JSON.stringify([{ category: "g", content: "fato" }]); + }, + }; + + const stats = await runMemoryExtraction(deps); + + assert.equal(stats.signals, 1); + assert.equal(capture.upserts.length, 1); + assert.equal(capture.upserts[0].account_id, "friend:ok"); +}); + +// --------------------------------------------------------------------------- +// AC7 — isolamento entre contas +// --------------------------------------------------------------------------- + +test("isolamento: processando a conta A nunca lê a credencial nem escreve fato com account_id de B", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + const deps = makeDeps({ + accounts: ["A", "B"], + memoriesByAccount: { + A: [{ source_id: "a1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }], + B: [{ source_id: "b1", text: "y", ts: new Date("2026-06-11T10:00:00Z") }], + }, + llmResponse: () => JSON.stringify([{ category: "g", content: "fato" }]), + capture, + }); + + await runMemoryExtraction(deps); + + // A credencial só é lida da conta sendo processada (cada uma a sua). + assert.deepEqual(capture.credLoads.sort(), ["A", "B"]); + // Cada upsert/audit carrega o account_id da conta da própria iteração. + const upsertA = capture.upserts.filter((u) => u.account_id === "A"); + const upsertB = capture.upserts.filter((u) => u.account_id === "B"); + assert.equal(upsertA.length, 1); + assert.equal(upsertB.length, 1); + // Nenhum upsert tem account_id cruzado. + for (const u of capture.upserts) { + assert.ok(u.account_id === "A" || u.account_id === "B"); + } +}); + +test("conta sem assinatura é pulada (não aparece em listAccountsWithSubscription)", async () => { + process.env.MEMORY_EXTRACT_ENABLED = "true"; + const capture = emptyCapture(); + // 'no-sub' tem memórias mas NÃO está na lista de contas com assinatura. + const deps = makeDeps({ + accounts: ["has-sub"], + memoriesByAccount: { + "has-sub": [{ source_id: "h1", text: "x", ts: new Date("2026-06-10T10:00:00Z") }], + "no-sub": [{ source_id: "n1", text: "y", ts: new Date("2026-06-11T10:00:00Z") }], + }, + llmResponse: () => JSON.stringify([{ category: "g", content: "fato" }]), + capture, + }); + + const stats = await runMemoryExtraction(deps); + + assert.equal(stats.accounts, 1); + assert.deepEqual(capture.credLoads, ["has-sub"]); + assert.ok(!capture.upserts.some((u) => u.account_id === "no-sub")); +}); diff --git a/src/rag/memory-extraction-tick.ts b/src/rag/memory-extraction-tick.ts new file mode 100644 index 0000000..258ece9 --- /dev/null +++ b/src/rag/memory-extraction-tick.ts @@ -0,0 +1,82 @@ +// src/rag/memory-extraction-tick.ts +// AC8 (spec 2026-06-19-curadoria-assinatura-claude-design) — o tick que agenda o +// extrator de memória (powered pela assinatura Claude da conta) no brain-classifier. +// +// Espelha memory-curation-tick.ts: vive num módulo próprio (e não inline no +// index-classifier.ts) para ser testável sem arrastar a cadeia de imports do +// entrypoint. Características: +// - gate MEMORY_EXTRACT_ENABLED (=== 'true', default off) — o kill-switch fica +// DENTRO do tick, então o schedule existe sempre mas o tick é no-op com a flag +// desligada (não gasta a assinatura de ninguém); +// - lazy-import das deps REAIS (runMemoryExtraction + helpers de +// account_secrets/profile-storage/storage/claude-subscription-store); +// - try/catch com log de 1 linha; +// - recordRun(worker='classifier', source='memory-extraction', ...). +import { recordRun } from "./storage.js"; + +/** Stats do extrator, reexportado pra tipar o seam de teste. */ +export interface MemoryExtractionTickStats { + accounts: number; + extracted: number; + signals: number; +} + +/** + * Roda o extrator uma vez, atrás do gate. O 2º parâmetro `run` é um test seam: o + * teste de gating injeta um fake e verifica que ele só é chamado com a flag + * ligada (sem DB/assinatura). Em produção `run` é omitido e as deps reais são + * lazy-importadas. Erros nunca propagam — são logados e registrados em recordRun. + */ +export async function tickMemoryExtraction( + label: string, + run?: () => Promise, +): Promise { + if (process.env.MEMORY_EXTRACT_ENABLED !== "true") return; + const start = Date.now(); + try { + let stats: MemoryExtractionTickStats; + if (run) { + stats = await run(); + } else { + const { runMemoryExtraction } = await import("./memory-extractor.js"); + const { listAccountsWithSubscription, loadMemoryExtractCursor, saveMemoryExtractCursor } = + await import("../claude-subscription-store.js"); + const { loadCredential, callSubscription } = await import("../claude-subscription-store.js"); + const { upsertProfileFact, insertMemoryAudit } = await import("./profile-storage.js"); + const { listConversationMemoriesSince } = await import("./storage.js"); + stats = await runMemoryExtraction({ + listAccountsWithSubscription: () => listAccountsWithSubscription(), + loadCredential: (accountId) => loadCredential(accountId), + loadCursor: (accountId) => loadMemoryExtractCursor(accountId), + saveCursor: (accountId, cursor) => saveMemoryExtractCursor(accountId, cursor), + loadRecentMemories: (accountId, since, limit) => + listConversationMemoriesSince(accountId, since, limit), + callSubscription: (accountId, opts) => callSubscription(accountId, opts), + upsertFact: (fact) => upsertProfileFact(fact), + insertAudit: (row) => insertMemoryAudit(row), + }); + } + console.log( + `[${new Date().toISOString()}] [memory-extraction:${label}] accounts=${stats.accounts} extracted=${stats.extracted} signals=${stats.signals} took=${Date.now() - start}ms`, + ); + await recordRun({ + worker: "classifier", + source: "memory-extraction", + ok: true, + counts: stats, + startedAt: new Date(start), + endedAt: new Date(), + }); + } catch (err) { + const msg = `[${new Date().toISOString()}] [memory-extraction:${label}] FAILED: ${err instanceof Error ? err.message : String(err)}`; + console.error(msg); + await recordRun({ + worker: "classifier", + source: "memory-extraction", + ok: false, + error: err instanceof Error ? err.message : String(err), + startedAt: new Date(start), + endedAt: new Date(), + }); + } +} diff --git a/src/rag/memory-extractor.ts b/src/rag/memory-extractor.ts new file mode 100644 index 0000000..c0ce80b Binary files /dev/null and b/src/rag/memory-extractor.ts differ diff --git a/src/rag/storage.ts b/src/rag/storage.ts index 78c5e87..beb63b8 100644 --- a/src/rag/storage.ts +++ b/src/rag/storage.ts @@ -895,3 +895,46 @@ export async function listBrainDocuments( doc_date: r.doc_date ? new Date(r.doc_date).toISOString().slice(0, 10) : null, })); } + +/** One conversation memory (a `remember` note) for the LLM extractor. Read-only: + * source_id is the citation pointer; `ts` is the memory's own timestamp. */ +export interface ConversationMemory { + source_id: string; + text: string; + ts: Date; +} + +/** + * READ-ONLY: list ONE account's recent conversation memories (source_type = + * 'conversation', i.e. the `remember` notes) strictly NEWER than `since` (cursor), + * oldest-first, capped at `limit`. account_id is the bound $1 and the ONLY tenant + * filter — one account can never read another's memories. This never mutates + * brain_chunks; it only feeds the memory extractor. `ts` uses source_updated when + * present (the memory's real time) else indexed_at, so the cursor always advances. + */ +export async function listConversationMemoriesSince( + accountId: string, + since: Date | null, + limit: number, + pool?: PoolLike, +): Promise { + const p = pool ?? getPool(); + const params: unknown[] = [accountId]; + let sinceClause = ""; + if (since) { + params.push(since.toISOString()); + sinceClause = `AND COALESCE(source_updated, indexed_at) > $${params.length}`; + } + params.push(limit); + const { rows } = await p.query<{ source_id: string; text: string; ts: Date }>( + `SELECT source_id, text, COALESCE(source_updated, indexed_at) AS ts + FROM brain_chunks + WHERE account_id = $1 + AND source_type = 'conversation' + ${sinceClause} + ORDER BY ts ASC + LIMIT $${params.length}`, + params, + ); + return rows.map((r) => ({ source_id: r.source_id, text: r.text, ts: new Date(r.ts) })); +} diff --git a/web/app/(app)/perfil/page.tsx b/web/app/(app)/perfil/page.tsx index fc78d2e..c45b4ee 100644 --- a/web/app/(app)/perfil/page.tsx +++ b/web/app/(app)/perfil/page.tsx @@ -3,6 +3,7 @@ import { AddFactForm, BudgetMeter, + ClaudeConnectionCard, FactList, ProfileHeader, } from "@/components/profile"; @@ -69,6 +70,7 @@ export default function Page() {
+
diff --git a/web/components/profile/ClaudeConnectionCard.tsx b/web/components/profile/ClaudeConnectionCard.tsx new file mode 100644 index 0000000..bec57ae --- /dev/null +++ b/web/components/profile/ClaudeConnectionCard.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState } from "react"; +import { + Button, + Card, + CardBody, + CardHead, + CardSub, + CardTitle, + Skeleton, + Tag, + TextField, + useToast, +} from "@/components/ui"; +import { ApiError } from "@/lib/api"; +import type { ClaudeStatusResponse } from "@/lib/contracts"; +import { useClaudeConnect, useClaudeDisconnect, useClaudeStatus } from "@/hooks/claude"; + +/** Formats an ISO expiry as a pt-BR date, or null when absent/unparseable. */ +function formatExpiry(iso: string | undefined): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString("pt-BR", { + day: "2-digit", + month: "long", + year: "numeric", + }); +} + +/** Maps a connect error body to a user-facing message. */ +function messageForConnectError(err: unknown): string { + // 400 invalid_credential (and the empty/too-large 400/413 guards) all mean the + // pasted credential is wrong — one clear message covers them. + if (err instanceof ApiError && (err.status === 400 || err.status === 413)) { + return "Credencial inválida; verifique o que você colou."; + } + return "Não consegui conectar agora. Tente de novo em instantes."; +} + +const shell = ( + status: React.ReactNode, + children: React.ReactNode +) => ( + + +
+ Conexão Claude + + Conecte sua assinatura Claude pra que o Zinom mantenha seu perfil + sozinho, usando a sua conexão (no seu custo). + +
+ {status} +
+ {children} +
+); + +/** The paste form (textarea + connect). Shared by the disconnected and the reauth states. */ +function ConnectForm() { + const show = useToast((s) => s.show); + const connect = useClaudeConnect(); + const [credential, setCredential] = useState(""); + const [error, setError] = useState(null); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const value = credential.trim(); + if (!value) { + setError("Cole a credencial gerada pelo comando acima."); + return; + } + setError(null); + connect.mutate( + { credential: value }, + { + onSuccess: () => { + // Never keep the credential around once it's been sent. + setCredential(""); + show("Assinatura Claude conectada", "success"); + }, + onError: (err) => setError(messageForConnectError(err)), + } + ); + }; + + return ( +
+
    +
  1. + No seu terminal, rode{" "} + + claude setup-token + + . +
  2. +
  3. Cole o resultado completo no campo abaixo e conecte.
  4. +
+ + { + setCredential(e.target.value); + if (error) setError(null); + }} + disabled={connect.isPending} + /> + +
+ +
+ + ); +} + +/** The connected state: expiry line + disconnect with a two-step inline confirm. */ +function ConnectedView({ status }: { status: ClaudeStatusResponse }) { + const show = useToast((s) => s.show); + const disconnect = useClaudeDisconnect(); + const [confirming, setConfirming] = useState(false); + const expiry = formatExpiry(status.expires_at); + + const onDisconnect = () => { + disconnect.mutate(undefined, { + onSuccess: () => { + setConfirming(false); + show("Assinatura Claude desconectada", "success"); + }, + onError: () => { + setConfirming(false); + show("Não consegui desconectar agora", "error"); + }, + }); + }; + + return ( +
+ {expiry ? ( +

+ Sua assinatura está conectada e expira em{" "} + {expiry}. +

+ ) : ( +

+ Sua assinatura está conectada. +

+ )} + +
+ {confirming ? ( + <> + Desconectar a assinatura? + + + + ) : ( + + )} +
+
+ ); +} + +/** + * "Conexão Claude" card on the Perfil page. Lets the user connect their Claude + * subscription (pasting the `claude setup-token` credential), see the status and + * expiry, and disconnect. When the connection needs re-auth, it shows a warning + * and reopens the paste form so the user can reconnect in place. The raw + * credential is never read back nor shown after it's been sent. + */ +export function ClaudeConnectionCard() { + const status = useClaudeStatus(); + + if (status.isLoading) { + return shell( + , +
+ + +
+ ); + } + + if (status.isError || !status.data) { + return shell( + + Indisponível + , +

+ Não consegui verificar a conexão com a Claude agora. Recarregue a página + em instantes. +

+ ); + } + + const { connected, needs_reauth } = status.data; + + // Disconnected (or never connected): show instructions + paste form. + if (!connected) { + return shell( + + Desconectado + , + + ); + } + + // Connected but stale: warn and reopen the form so the user can reconnect. + if (needs_reauth) { + return shell( + + Reconectar + , + <> +

+ Reconecte sua assinatura: a conexão expirou ou não pôde ser renovada. + Gere uma nova credencial e cole abaixo. +

+ + + ); + } + + // Connected and healthy. + return shell( + + Conectado + , + + ); +} diff --git a/web/components/profile/claude-connection.test.tsx b/web/components/profile/claude-connection.test.tsx new file mode 100644 index 0000000..49c7dd5 --- /dev/null +++ b/web/components/profile/claude-connection.test.tsx @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { createElement } from "react"; +import * as api from "@/lib/api"; +import { ClaudeConnectionCard } from "./ClaudeConnectionCard"; +import type { ClaudeStatusResponse } from "@/lib/contracts"; + +function wrap(node: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(createElement(QueryClientProvider, { client: qc }, node)); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("ClaudeConnectionCard", () => { + it("renders the disconnected state with the paste form", async () => { + const status: ClaudeStatusResponse = { connected: false }; + vi.spyOn(api, "apiFetch").mockResolvedValue(status); + + const { container, findByText } = wrap(createElement(ClaudeConnectionCard)); + + expect(await findByText("Desconectado")).toBeTruthy(); + expect( + container.querySelector("[data-perfil-claude-credential]") + ).toBeTruthy(); + expect(container.querySelector("[data-perfil-claude-connect]")).toBeTruthy(); + // no disconnect button while disconnected + expect( + container.querySelector("[data-perfil-claude-disconnect]") + ).toBeNull(); + }); + + it("renders the connected state with the expiry and a disconnect action", async () => { + const status: ClaudeStatusResponse = { + connected: true, + expires_at: "2027-01-15T00:00:00Z", + }; + vi.spyOn(api, "apiFetch").mockResolvedValue(status); + + const { container, findByText } = wrap(createElement(ClaudeConnectionCard)); + + expect(await findByText("Conectado")).toBeTruthy(); + expect( + container.querySelector("[data-perfil-claude-disconnect]") + ).toBeTruthy(); + // no paste form while healthy + connected + expect( + container.querySelector("[data-perfil-claude-credential]") + ).toBeNull(); + }); + + it("submits the pasted credential through apiFetch", async () => { + const spy = vi + .spyOn(api, "apiFetch") + .mockImplementation((path: string) => { + if (path === "/portal/claude/status") { + return Promise.resolve({ connected: false } as ClaudeStatusResponse); + } + return Promise.resolve({ + ok: true, + expires_at: "2027-01-15T00:00:00Z", + }); + }); + + const { container, findByText } = wrap(createElement(ClaudeConnectionCard)); + await findByText("Desconectado"); + + const textarea = container.querySelector( + "[data-perfil-claude-credential]" + ) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "sk-ant-oat01-abc" } }); + + await act(async () => { + fireEvent.submit(textarea.closest("form")!); + }); + + await vi.waitFor(() => + expect(spy).toHaveBeenCalledWith("/portal/claude/connect", { + method: "POST", + body: { credential: "sk-ant-oat01-abc" }, + }) + ); + }); + + it("shows a clear message on a 400 invalid_credential", async () => { + vi.spyOn(api, "apiFetch").mockImplementation((path: string) => { + if (path === "/portal/claude/status") { + return Promise.resolve({ connected: false } as ClaudeStatusResponse); + } + return Promise.reject( + new api.ApiError(400, { error: "invalid_credential" }) + ); + }); + + const { container, findByText } = wrap(createElement(ClaudeConnectionCard)); + await findByText("Desconectado"); + + const textarea = container.querySelector( + "[data-perfil-claude-credential]" + ) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "garbage" } }); + fireEvent.submit(textarea.closest("form")!); + + expect(await findByText(/credencial inválida/i)).toBeTruthy(); + }); + + it("warns and reopens the form when reconnection is needed", async () => { + const status: ClaudeStatusResponse = { + connected: true, + needs_reauth: true, + expires_at: "2025-01-15T00:00:00Z", + }; + vi.spyOn(api, "apiFetch").mockResolvedValue(status); + + const { container, findByText } = wrap(createElement(ClaudeConnectionCard)); + + expect(await findByText("Reconectar")).toBeTruthy(); + expect(container.querySelector("[data-perfil-claude-reauth]")).toBeTruthy(); + // form is reopened so the user can paste a fresh credential in place + expect( + container.querySelector("[data-perfil-claude-credential]") + ).toBeTruthy(); + }); +}); diff --git a/web/components/profile/index.ts b/web/components/profile/index.ts index fee3210..6eeddfb 100644 --- a/web/components/profile/index.ts +++ b/web/components/profile/index.ts @@ -1,5 +1,6 @@ export * from "./ProfileHeader"; export * from "./BudgetMeter"; +export * from "./ClaudeConnectionCard"; export * from "./AddFactForm"; export * from "./FactCard"; export * from "./FactList"; diff --git a/web/hooks/claude.ts b/web/hooks/claude.ts new file mode 100644 index 0000000..fa2784e --- /dev/null +++ b/web/hooks/claude.ts @@ -0,0 +1,83 @@ +"use client"; + +// Typed React Query hooks for the "Conexão Claude" card on the Perfil view. +// +// Read: useClaudeStatus (/portal/claude/status → ClaudeStatusResponse). +// Writes (connect / disconnect) both invalidate the ["claude","status"] query so +// the card re-renders from fresh server state — the same refetch-after-mutation +// pattern used by perfil.ts / the Fontes cards. We never read or surface the raw +// credential: only the status (connected, expiry, needs_reauth) flows back. +// +// queryKeys.ts is a shared module we don't edit, so the Claude-only key lives +// here, mirroring perfilKeys. + +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { apiFetch } from "@/lib/api"; +import type { + ClaudeConnectRequest, + ClaudeConnectResponse, + ClaudeStatusResponse, + OkResponse, +} from "@/lib/contracts"; + +export const claudeKeys = { + status: ["claude", "status"] as const, +} as const; + +// --- read ------------------------------------------------------------------ + +/** /portal/claude/status — connected? expiry? needs reauth? (never the token). */ +export function useClaudeStatus(): UseQueryResult { + return useQuery({ + queryKey: claudeKeys.status, + queryFn: () => apiFetch("/portal/claude/status"), + }); +} + +// --- mutations ------------------------------------------------------------- + +function useInvalidateClaude() { + const qc = useQueryClient(); + return () => qc.invalidateQueries({ queryKey: claudeKeys.status }); +} + +/** + * POST /portal/claude/connect {credential} → 200 {ok,expires_at}. Throws + * ApiError(400) with {error:'invalid_credential'} (or 413 when too large); the + * card surfaces those inline. + */ +export function useClaudeConnect(): UseMutationResult< + ClaudeConnectResponse, + unknown, + ClaudeConnectRequest +> { + const invalidate = useInvalidateClaude(); + return useMutation({ + mutationFn: (body: ClaudeConnectRequest) => + apiFetch("/portal/claude/connect", { + method: "POST", + body, + }), + onSuccess: invalidate, + }); +} + +/** POST /portal/claude/disconnect → 200 {ok:true}. */ +export function useClaudeDisconnect(): UseMutationResult< + OkResponse, + unknown, + void +> { + const invalidate = useInvalidateClaude(); + return useMutation({ + mutationFn: () => + apiFetch("/portal/claude/disconnect", { method: "POST" }), + onSuccess: invalidate, + }); +} diff --git a/web/lib/contracts.ts b/web/lib/contracts.ts index b93edb8..3463c15 100644 --- a/web/lib/contracts.ts +++ b/web/lib/contracts.ts @@ -702,6 +702,29 @@ export interface ProfileEvidenceRequest { kind: ProfileEvidenceKind; } +// --------------------------------------------------------------------------- +// Conexao Claude (assinatura) - /portal/claude +// --------------------------------------------------------------------------- + +/** GET /portal/claude/status — never exposes the raw token. */ +export interface ClaudeStatusResponse { + connected: boolean; + /** ISO timestamp the connection expires (synthetic TTL for bare tokens). */ + expires_at?: string; + /** True when the connection expired / refresh failed and must be reconnected. */ + needs_reauth?: boolean; +} + +export interface ClaudeConnectRequest { + /** The credential pasted from `claude setup-token` (bare token or JSON). */ + credential: string; +} + +export interface ClaudeConnectResponse { + ok: true; + expires_at: string; +} + // --------------------------------------------------------------------------- // Sessoes do portal // ---------------------------------------------------------------------------