diff --git a/.aiox-core/cli/commands/pro/buyer.js b/.aiox-core/cli/commands/pro/buyer.js new file mode 100644 index 0000000000..afed739148 --- /dev/null +++ b/.aiox-core/cli/commands/pro/buyer.js @@ -0,0 +1,349 @@ +/** + * Pro Buyer Subcommand Module + * + * CLI commands for AIOX Pro buyer validation and management. + * Consumes aiox-license-server via pro/license/license-api.js. + * + * Subcommands (Wave 1 — this file): + * aiox pro buyer validate --email [--json] + * aiox pro buyer validate-batch --file [--concurrency N] [--json] + * + * Subcommands (Wave 2 — depends on POST /api/v1/admin/buyers/register in + * aiox-license-server; not yet implemented): + * aiox pro buyer register --email --name [--cpf ] [--yes] [--json] + * + * @module cli/commands/pro/buyer + * @story 123.8 — Cohort Buyer CLI Migration + * @see docs/architecture/design-cohort-buyer-cli-migration.md + */ + +'use strict'; + +const { Command } = require('commander'); +const fs = require('fs'); +const path = require('path'); + +// Dynamic license path resolution — duplicates pattern from pro/index.js so +// buyer.js can be loaded independently. Kept intentionally self-contained. +function resolveLicensePath() { + const relativePath = path.resolve(__dirname, '..', '..', '..', '..', 'pro', 'license'); + if (fs.existsSync(relativePath)) { + return relativePath; + } + + try { + const proPkg = require.resolve('@aiox-fullstack/pro/package.json'); + const proDir = path.dirname(proPkg); + const npmPath = path.join(proDir, 'license'); + if (fs.existsSync(npmPath)) { + return npmPath; + } + } catch { + // fall through + } + + const cwdPath = path.join(process.cwd(), 'node_modules', '@aiox-fullstack', 'pro', 'license'); + if (fs.existsSync(cwdPath)) { + return cwdPath; + } + + return relativePath; +} + +const licensePath = resolveLicensePath(); + +function loadClient() { + try { + const { licenseApi } = require(path.join(licensePath, 'license-api')); + return licenseApi; + } catch (error) { + console.error('Erro: módulo AIOX Pro license não disponível.'); + console.error('Instale: npm install @aiox-fullstack/pro'); + console.error(`Detalhe: ${error.message}`); + process.exit(2); + } +} + +/** + * Email format check — minimal, server-side validates definitively. + */ +function isValidEmail(email) { + if (typeof email !== 'string' || email.length === 0 || email.length > 254) { + return false; + } + // Simple RFC 5322-lite: no whitespace, one @, dot in domain + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +/** + * Classify error into exit code and human-friendly message. + * Exit codes per story 123.8 ACs: + * 0 success, 1 validation failed (isBuyer: false), + * 2 transport/server, 3 auth (reserved for Wave 2 register). + */ +function classifyError(err) { + const code = err && err.code ? err.code : null; + + // Network / transport / server + if (code === 'NETWORK_ERROR' || code === 'SERVER_ERROR') { + return { + exitCode: 2, + message: 'Falha de rede/servidor. Tente novamente em instantes.', + hint: 'Para mais detalhes: AIOX_DEBUG=true aiox pro buyer validate ...', + }; + } + if (code === 'AUTH_RATE_LIMITED' || code === 'RATE_LIMITED') { + const retry = err.details && err.details.retryAfter ? ` (retry em ${err.details.retryAfter}s)` : ''; + return { + exitCode: 2, + message: `Rate limit atingido${retry}.`, + hint: 'Aguarde antes de tentar novamente.', + }; + } + + // Default: unknown / bad request + return { + exitCode: 2, + message: err && err.message ? err.message : 'Erro desconhecido.', + hint: null, + }; +} + +/** + * Emit result to stdout. + * @param {object} payload - Shape { email, isBuyer, hasAccount } + * @param {boolean} asJson - If true, emit JSON only (no decorative text) + */ +function emitValidateResult(payload, asJson) { + if (asJson) { + process.stdout.write(`${JSON.stringify(payload)}\n`); + return; + } + const statusIcon = payload.isBuyer ? '✅' : '❌'; + const buyerLabel = payload.isBuyer ? 'Sim' : 'Não'; + const accountLabel = payload.hasAccount ? 'Sim' : 'Não'; + process.stdout.write( + `\n${statusIcon} ${payload.email}\n` + + ` Buyer: ${buyerLabel}\n` + + ` Account: ${accountLabel}\n\n`, + ); +} + +// --------------------------------------------------------------------------- +// aiox pro buyer validate +// --------------------------------------------------------------------------- + +async function validateAction(options) { + const email = options && options.email; + const asJson = Boolean(options && options.json); + + if (!isValidEmail(email)) { + if (asJson) { + process.stdout.write(`${JSON.stringify({ error: 'INVALID_EMAIL', email })}\n`); + } else { + process.stderr.write('Erro: email inválido.\n'); + } + process.exit(2); + } + + const client = loadClient(); + + try { + const result = await client.validateBuyer(email); + emitValidateResult(result, asJson); + process.exit(result.isBuyer ? 0 : 1); + } catch (err) { + const classified = classifyError(err); + if (asJson) { + process.stdout.write(`${JSON.stringify({ + error: err && err.code ? err.code : 'UNKNOWN', + message: classified.message, + email, + })}\n`); + } else { + process.stderr.write(`\nFalha: ${classified.message}\n`); + if (classified.hint) { + process.stderr.write(`${classified.hint}\n`); + } + } + process.exit(classified.exitCode); + } +} + +// --------------------------------------------------------------------------- +// aiox pro buyer validate-batch +// --------------------------------------------------------------------------- + +/** + * Run `worker(item)` over `items` with bounded parallelism. + * Keeps order of results matching input. + */ +async function mapWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length); + let cursor = 0; + + async function next() { + while (true) { + const idx = cursor++; + if (idx >= items.length) return; + results[idx] = await worker(items[idx], idx); + } + } + + const limit = Math.max(1, Math.min(concurrency, items.length)); + const workers = []; + for (let i = 0; i < limit; i += 1) { + workers.push(next()); + } + await Promise.all(workers); + return results; +} + +function parseEmailsFile(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + return raw + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +async function validateBatchAction(options) { + const filePath = options && options.file; + const asJson = Boolean(options && options.json); + const concurrencyRaw = options && options.concurrency ? Number(options.concurrency) : 5; + const concurrency = Math.max(1, Math.min(10, Number.isFinite(concurrencyRaw) ? concurrencyRaw : 5)); + + if (!filePath || !fs.existsSync(filePath)) { + const msg = filePath ? `Arquivo não encontrado: ${filePath}` : 'Erro: --file é obrigatório.'; + if (asJson) { + process.stdout.write(`${JSON.stringify({ error: 'FILE_NOT_FOUND', file: filePath })}\n`); + } else { + process.stderr.write(`${msg}\n`); + } + process.exit(2); + } + + let emails; + try { + emails = parseEmailsFile(filePath); + } catch (err) { + if (asJson) { + process.stdout.write(`${JSON.stringify({ error: 'FILE_READ_ERROR', message: err.message })}\n`); + } else { + process.stderr.write(`Falha ao ler arquivo: ${err.message}\n`); + } + process.exit(2); + } + + if (emails.length === 0) { + if (asJson) { + process.stdout.write('[]\n'); + } else { + process.stdout.write('Nenhum email no arquivo.\n'); + } + process.exit(0); + } + + const client = loadClient(); + + const results = await mapWithConcurrency(emails, concurrency, async (email) => { + if (!isValidEmail(email)) { + return { email, isBuyer: false, hasAccount: false, error: 'INVALID_EMAIL' }; + } + try { + const r = await client.validateBuyer(email); + return { email: r.email, isBuyer: r.isBuyer, hasAccount: r.hasAccount }; + } catch (err) { + return { + email, + isBuyer: false, + hasAccount: false, + error: err && err.code ? err.code : 'UNKNOWN', + }; + } + }); + + if (asJson) { + process.stdout.write(`${JSON.stringify(results)}\n`); + } else { + for (const r of results) { + if (r.error) { + process.stdout.write(`⚠️ ${r.email} [${r.error}]\n`); + } else { + const icon = r.isBuyer ? '✅' : '❌'; + process.stdout.write(`${icon} ${r.email} buyer=${r.isBuyer} account=${r.hasAccount}\n`); + } + } + const successes = results.filter((r) => !r.error && r.isBuyer).length; + process.stdout.write(`\n${successes}/${results.length} buyers.\n`); + } + + const anyFailure = results.some((r) => r.error || !r.isBuyer); + process.exit(anyFailure ? 1 : 0); +} + +// --------------------------------------------------------------------------- +// aiox pro buyer register — Wave 2 stub (hidden until endpoint exists) +// --------------------------------------------------------------------------- + +async function registerAction() { + process.stderr.write( + '\nOperação `register` pendente (Wave 2 da Story 123.8).\n' + + 'Depende do endpoint POST /api/v1/admin/buyers/register no repo aiox-license-server,\n' + + 'que ainda não foi implementado.\n\n' + + 'Acompanhe em docs/stories/epic-123/STORY-123.8-cohort-buyer-cli-migration.md\n', + ); + process.exit(2); +} + +// --------------------------------------------------------------------------- +// Command builder +// --------------------------------------------------------------------------- + +/** + * Create the `aiox pro buyer` subcommand group. + * @returns {Command} + */ +function createBuyerCommand() { + const cmd = new Command('buyer') + .description('Validar e gerenciar buyers AIOX Pro (Cohort admin)'); + + cmd + .command('validate') + .description('Verificar se um email é comprador AIOX Pro') + .requiredOption('-e, --email ', 'Email do buyer a validar') + .option('--json', 'Emitir saída JSON estável (sem decoração)') + .action(validateAction); + + cmd + .command('validate-batch') + .description('Validar múltiplos emails de um arquivo (bounded concurrency)') + .requiredOption('-f, --file ', 'Arquivo com um email por linha') + .option('-c, --concurrency ', 'Requisições paralelas (default 5, máx 10)', '5') + .option('--json', 'Emitir saída JSON (array de resultados)') + .action(validateBatchAction); + + // Wave 2 stub — kept so the CLI surface is stable once endpoint lands. + cmd + .command('register') + .description('Cadastrar novo buyer (pendente Wave 2 — endpoint cross-repo)') + .option('-e, --email ', 'Email do buyer') + .option('-n, --name ', 'Nome do buyer') + .option('--cpf ', 'CPF (opcional)') + .option('-y, --yes', 'Pular confirmação') + .option('--json', 'Emitir saída JSON') + .action(registerAction); + + return cmd; +} + +module.exports = { + createBuyerCommand, + // Exports internos para testes: + _internal: { + isValidEmail, + classifyError, + parseEmailsFile, + mapWithConcurrency, + }, +}; diff --git a/.aiox-core/cli/commands/pro/index.js b/.aiox-core/cli/commands/pro/index.js index f7bd06723e..733f24009e 100644 --- a/.aiox-core/cli/commands/pro/index.js +++ b/.aiox-core/cli/commands/pro/index.js @@ -22,6 +22,7 @@ const { Command } = require('commander'); const path = require('path'); const fs = require('fs'); const readline = require('readline'); +const { createBuyerCommand } = require('./buyer'); // BUG-6 fix (INS-1): Dynamic licensePath resolution // In framework-dev: __dirname = aiox-core/.aiox-core/cli/commands/pro → ../../../../pro/license @@ -695,6 +696,9 @@ function createProCommand() { .option('--verify', 'Only verify installation without installing') .action(setupAction); + // aiox pro buyer — Cohort admin operations (Story 123.8) + proCmd.addCommand(createBuyerCommand()); + return proCmd; } diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 05816fa4fd..f1fede7264 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T15:04:09.395Z" +generated_at: "2026-04-28T14:34:49.346Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -100,10 +100,14 @@ files: hash: sha256:fe8fa2479ff81e880a2570cfd3cf06ce548b9f1fbbb673493480ad5a102e47fb type: cli size: 12326 + - path: cli/commands/pro/buyer.js + hash: sha256:ed972e1b46873fd4d5de12ab7b4e0fe1b7e7df6ec47f49cc1a3f7ec59c1a770a + type: cli + size: 10945 - path: cli/commands/pro/index.js - hash: sha256:3e26f15119719a7be374f3db1935e6bd35fc2c14a66a2a30175979b5731bb29b + hash: sha256:f3382f226d7da791e294a5c6a2deb0cda7dcefb2825519da301fec7dd3cc1290 type: cli - size: 21940 + size: 22097 - path: cli/commands/qa/index.js hash: sha256:3a9e30419a66e56781f9b5dcddc8f4dd0ed24dabf8fe8c3005cd26f5cb02558f type: cli diff --git a/.claude/commands/cohort-squad/agents/cohort-manager.md b/.claude/commands/cohort-squad/agents/cohort-manager.md index 0845f7c9cd..5a14e78c6a 100644 --- a/.claude/commands/cohort-squad/agents/cohort-manager.md +++ b/.claude/commands/cohort-squad/agents/cohort-manager.md @@ -76,6 +76,8 @@ persona: - NUNCA listar ou buscar dados de buyers existentes - SEMPRE confirmar antes de executar register_buyer - Este squad NUNCA deve ser commitado ao repositorio + - AIOX_BUYER_ADMIN_KEY e lida do environment; nunca exibida em transcript nem output (Story 123.8) + - CLI nativa `aiox pro buyer` substitui MCP tools (Wave 1 entregue em Story 123.8) commands: - name: help @@ -99,8 +101,7 @@ dependencies: - validate-buyer.md - register-buyer.md tools: - - cohort_validate_buyer # MCP tool (read-only) - - cohort_register_buyer # MCP tool (write) + - bash # Invoca `aiox pro buyer` CLI (Story 123.8 — migrado de MCP para CLI nativa) ``` --- @@ -116,19 +117,24 @@ dependencies: ## Workflow Padrao -### Validar Buyer +> **Story 123.8 (2026-04-22):** migrado de MCP para CLI nativa. Agente invoca +> `aiox pro buyer` via Bash tool em vez de tools MCP. + +### Validar Buyer — Wave 1 (ativo) ``` -*validate → informar email → cohort_validate_buyer → resultado +*validate → informar email → Bash("aiox pro buyer validate --email --json") → parse JSON → resultado ``` -### Registrar Buyer +### Batch Validate — Wave 1 (ativo) ``` -*register → informar nome + email + cpf? → confirmar dados → cohort_register_buyer → resultado +*validate-batch → lista de emails em arquivo → Bash("aiox pro buyer validate-batch --file --json") → tabela de resultados ``` -### Batch Validate +### Registrar Buyer — Wave 2 (pendente) ``` -*validate-batch → lista de emails → cohort_validate_buyer (loop) → tabela de resultados +*register → pendente: endpoint POST /api/v1/admin/buyers/register em aiox-license-server ainda não existe. + → Quando implementado: Bash("AIOX_BUYER_ADMIN_KEY=*** aiox pro buyer register --email --name --yes") + → Ver Story 123.8 para roadmap. ``` --- diff --git a/docs/guides/supabase-ops-handoff.md b/docs/guides/supabase-ops-handoff.md new file mode 100644 index 0000000000..aac0a800df --- /dev/null +++ b/docs/guides/supabase-ops-handoff.md @@ -0,0 +1,765 @@ +# AIOX-Pro Access Ops Handoff + +**Version:** 3.0.0 +**Last Updated:** 2026-04-19 +**Status:** Active + +--- + +## Overview + +Este handoff não é para descoberta genérica de Supabase. + +Ele existe para operações repetíveis do AIOX-Pro access/licensing, onde o projeto, o serviço e o fluxo já são conhecidos. O foco aqui é permitir que o squad-creator gere tasks específicas para operações como: + +- criar novo acesso +- liberar Pro para um e-mail existente +- reenviar verificação +- confirmar e-mail por admin +- reset de senha +- diagnosticar por que um login/acesso falhou + +O documento precisa ser profundo o suficiente para evitar que a task gerada repita investigação desnecessária ou tome atalhos perigosos. + +--- + +## Fixed Context + +Para este fluxo, os fatos já conhecidos são: + +- serviço real: `https://aiox-license-server.vercel.app` +- projeto Supabase correto: `aios-license-server` +- project ref: `evvvnarpwcdybxdvcwjh` +- auth backend: Supabase Auth do projeto `evvvnarpwcdybxdvcwjh` +- entitlement/buyer oracle: tabela `public.buyers` +- tabelas auxiliares relevantes: + - `public.buyer_validations` + - `public.licenses` + - `public.activations` + +**Regra:** para operações de acesso AIOX-Pro, não gastar tempo redescobrindo projeto. Começar direto deste contexto. + +--- + +## What We Already Learned In Practice + +Este fluxo já foi executado manualmente e os aprendizados abaixo devem ser tratados como conhecimento operacional consolidado: + +- `POST /api/v1/auth/check-email` é o pré-check oficial do backend + - ele retorna `isBuyer` e `hasAccount` + - ele deve ser o primeiro oracle de estado do usuário + +- `POST /api/v1/auth/login` é o segundo oracle + - se retornar `EMAIL_NOT_VERIFIED`, o problema é confirmação de e-mail + - se retornar `INVALID_CREDENTIALS`, o problema é senha + - se retornar `200`, auth está funcional + +- no projeto `evvvnarpwcdybxdvcwjh`, o login real depende do Supabase Auth + +- o entitlement Pro não vem de `public.licenses` + - o oracle operacional para buyer é `public.buyers` + - se `buyers` não tiver o e-mail ativo, `check-email` não sobe `isBuyer` + +- `public.licenses` e `public.activations` são importantes para licença e máquinas + - mas não são o primeiro write para “liberar acesso” + +- não é necessário ativar licença em máquina para concluir onboarding/access ops + - ativação consome seat/estado operacional e deve ficar fora de tasks de provisionamento básico + +--- + +## Operational Goal + +Toda task derivada deste handoff deve responder claramente: + +- o usuário já existe no auth? +- o e-mail está confirmado? +- o e-mail já está liberado em `buyers`? +- o fluxo final no serviço real funciona? + +--- + +## Known Endpoints + +Endpoints do serviço real que importam: + +- `POST /api/v1/auth/check-email` +- `POST /api/v1/auth/signup` +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/activate-pro` +- `POST /api/v1/auth/request-reset` +- `POST /api/v1/auth/resend-verification` + +UI/flow auxiliar: + +- reset de senha: `https://aiox-license-server.vercel.app/reset-password` + +--- + +## Endpoint Semantics + +### `POST /api/v1/auth/check-email` + +Usar para classificar o caso antes de qualquer write. + +Resposta de interesse: + +- `isBuyer: boolean` +- `hasAccount: boolean` +- `email` + +Interpretação: + +- `isBuyer=false`, `hasAccount=false` + - falta conta e falta entitlement +- `isBuyer=false`, `hasAccount=true` + - conta existe, falta buyer +- `isBuyer=true`, `hasAccount=false` + - caso inconsistente ou migração parcial; investigar auth +- `isBuyer=true`, `hasAccount=true` + - provisioning quase completo; validar login + +### `POST /api/v1/auth/signup` + +Usar somente quando `hasAccount=false`. + +Saída útil: + +- `userId` +- `message` + +### `POST /api/v1/auth/login` + +Usar para validar se o usuário consegue de fato entrar. + +Saídas/erros úteis: + +- `200` com `accessToken`, `userId`, `emailVerified` +- `EMAIL_NOT_VERIFIED` +- `INVALID_CREDENTIALS` + +### `POST /api/v1/auth/request-reset` + +Usar para recovery padrão quando não for desejável reset manual por admin. + +### `POST /api/v1/auth/resend-verification` + +Usar quando a conta existe mas ainda depende do inbox do usuário. + +--- + +## Tables That Matter + +### `public.buyers` + +Oracle de entitlement Pro. + +Campos relevantes: + +- `email` +- `source` +- `purchased_at` +- `is_active` +- `metadata` + +Regra prática: + +- se o e-mail não está ativo em `buyers`, `check-email` não vai retornar `isBuyer: true` +- para “liberar acesso Pro”, este é o write primário + +### `public.buyer_validations` + +Cache/registro de validação de buyer por usuário autenticado. + +Campos relevantes: + +- `user_id` +- `email` +- `is_valid` +- `validated_at` +- `expires_at` + +Regra prática: + +- é tabela de apoio/cache +- não é o primeiro write para grant manual +- pode ser inspecionada em diagnóstico, mas o provisioning deve preferir `buyers` + +### `public.licenses` + +Licenças emitidas pelo backend. + +Campos relevantes: + +- `key` +- `customer_email` +- `features` +- `max_seats` +- `expires_at` +- `user_id` + +Regra prática: + +- relevante para emissão/licença existente +- não usar como primeiro mecanismo de grant manual de acesso + +### `public.activations` + +Ativações por máquina. + +Campos relevantes: + +- `license_id` +- `machine_id` +- `activated_at` +- `deactivated_at` + +Regra prática: + +- só entra em cena quando o problema é ativação/seat/machine lifecycle +- não é etapa padrão de criar acesso ou reset de senha + +--- + +## Non-Negotiable Rules + +- não redescobrir projeto Supabase para AIOX-Pro access ops +- não escrever em `full-agent` para resolver licensing +- não inferir schema diferente do que já está confirmado acima +- não encerrar tarefa sem validar no serviço real +- não expor `service_role`, `anon`, JWT, reset token ou access token +- não consumir seat/activation sem necessidade explícita +- não escrever em `licenses` para resolver um caso que é apenas buyer/auth +- não usar `buyer_validations` como substitute de `buyers` + +--- + +## Mandatory Diagnostic Order + +Toda task deve seguir esta ordem, mesmo quando parecer óbvio o que está errado: + +1. `check-email` +2. `auth admin` por e-mail +3. `buyers` por e-mail +4. `login` se houver senha +5. `buyer_validations` apenas se houver dúvida de cache/estado intermediário +6. `licenses` e `activations` apenas se o problema incluir ativação/licença/máquina + +Justificativa: + +- esta ordem minimiza writes +- evita tocar licença quando o problema é apenas auth +- evita tocar auth quando o problema é apenas buyer +- evita consumir seat durante suporte básico + +--- + +## Decision Tree + +### Case A + +`check-email => isBuyer=false, hasAccount=false` + +Fazer: + +1. `signup` +2. confirmar e-mail por admin se acesso imediato for necessário +3. inserir `buyers` +4. revalidar `check-email` +5. validar `login` + +### Case B + +`check-email => isBuyer=false, hasAccount=true` + +Fazer: + +1. localizar auth user +2. inserir `buyers` se ausente +3. se login falhar por `EMAIL_NOT_VERIFIED`, confirmar e-mail por admin ou reenviar verificação +4. revalidar `check-email` +5. validar `login` + +### Case C + +`check-email => isBuyer=true, hasAccount=true`, mas login falha por `EMAIL_NOT_VERIFIED` + +Fazer: + +1. confirmar e-mail por admin ou reenviar verificação +2. revalidar `login` + +### Case D + +`check-email => isBuyer=true, hasAccount=true`, mas login falha por `INVALID_CREDENTIALS` + +Fazer: + +1. `request-reset` se o fluxo for self-service +2. ou update manual de senha por admin se o suporte precisar entregar senha provisória +3. validar `login` + +### Case E + +`check-email => isBuyer=true, hasAccount=false` + +Fazer: + +1. tratar como estado inconsistente +2. inspecionar auth admin +3. criar conta somente se confirmar ausência de user +4. não tocar licença antes de resolver auth + +--- + +## Standard Validation Sequence + +Toda task deve terminar com esta sequência: + +1. validar `check-email` +2. validar `login` se houver senha conhecida +3. só validar `activate-pro` se o objetivo da task for ativação real em máquina + +Estados esperados: + +- acesso liberado: + - `isBuyer: true` + - `hasAccount: true` +- login funcionando: + - status `200` + - `emailVerified: true` + +Se a task não consegue provar esses estados, ela não está concluída. + +--- + +## Evidence Pack Required + +Toda execução operacional deve sair com um pacote mínimo de evidências: + +- resultado inicial de `check-email` +- existência ou ausência do usuário no auth +- existência ou ausência do e-mail em `buyers` +- writes executados +- resultado final de `check-email` +- resultado final de `login`, se aplicável + +Formato esperado do resumo: + +- `initial_check` +- `auth_state` +- `buyer_state` +- `writes` +- `final_check` +- `final_login` + +--- + +## Playbook 1: Criar Novo Acesso Pro + +### Use When + +- o e-mail ainda não tem conta +- o e-mail precisa ganhar acesso Pro +- há senha inicial definida para onboarding/manual setup + +### Inputs + +- `email` +- `password` +- origem da liberação, ex.: `manual` +- motivo operacional + +### Steps + +1. checar `POST /api/v1/auth/check-email` +2. se `hasAccount: false`, criar conta com `signup` +3. localizar usuário no `auth admin` +4. confirmar e-mail por admin se a operação exigir acesso imediato +5. verificar se existe registro em `public.buyers` +6. se não existir, inserir buyer ativo +7. revalidar `check-email` +8. validar `login` + +### Writes Allowed + +- criar `auth user` +- inserir linha em `buyers` +- update admin de confirmação de e-mail + +### Writes Not Allowed + +- criar activation +- inventar linha em `licenses` +- alterar outras tabelas fora do fluxo + +### Success Criteria + +- `isBuyer: true` +- `hasAccount: true` +- `login` retorna `200` +- `emailVerified: true` + +### Minimal Data Write + +- `auth user` +- `public.buyers` + +--- + +## Playbook 2: Liberar Pro Para Conta Já Existente + +### Use When + +- o usuário já tem conta +- o problema é só falta de entitlement + +### Steps + +1. checar `POST /api/v1/auth/check-email` +2. confirmar que `hasAccount: true` +3. verificar `public.buyers` por e-mail +4. se ausente, inserir buyer ativo +5. revalidar `check-email` +6. validar `login` se a senha for conhecida + +### Writes Allowed + +- inserir ou corrigir `buyers` +- update admin de confirmação de e-mail, se necessário para destravar login + +### Success Criteria + +- `isBuyer: true` +- conta existente preservada +- nenhum write extra além de `buyers`, salvo necessidade explícita + +--- + +## Playbook 3: Reenviar Verificação de E-mail + +### Use When + +- a conta existe +- o login falha por e-mail não confirmado +- não é desejável confirmar por admin imediatamente + +### Steps + +1. confirmar que a conta existe +2. chamar `POST /api/v1/auth/resend-verification` +3. registrar que o usuário precisa abrir o link recebido +4. se a operação exigir liberação imediata, usar o Playbook 4 + +### Output Contract + +- informar explicitamente se ainda há dependência de ação do usuário +- não reportar “acesso resolvido” se ainda depende do inbox + +### Success Criteria + +- endpoint responde com sucesso +- comunicação deixa claro que a ação do usuário ainda é necessária + +--- + +## Playbook 4: Confirmar E-mail Por Admin + +### Use When + +- existe conta +- e-mail não confirmado +- é necessário desbloquear acesso imediatamente sem esperar inbox + +### Steps + +1. localizar o usuário no `auth admin` +2. aplicar update admin com `email_confirm: true` +3. revalidar login + +### Why This Exists + +- evita bloquear acesso imediato por dependência do inbox +- é o caminho de suporte quando a operação não pode esperar e-mail do usuário + +### Success Criteria + +- `email_confirmed_at` preenchido +- `login` retorna `200` + +### Caution + +- usar somente quando o processo permitir override administrativo + +--- + +## Playbook 5: Reset de Senha + +### Use When + +- o usuário esqueceu a senha +- não é necessário impor uma senha manual por admin + +### Steps + +1. confirmar se a conta existe +2. chamar `POST /api/v1/auth/request-reset` +3. orientar uso de `https://aiox-license-server.vercel.app/reset-password` +4. não mudar entitlement durante reset + +### Important Distinction + +- reset de senha não corrige buyer +- reset de senha não corrige e-mail não confirmado +- reset de senha é só para credenciais + +### Success Criteria + +- request-reset responde com sucesso +- usuário consegue seguir o fluxo de recuperação + +--- + +## Playbook 6: Definir Nova Senha Manualmente + +### Use When + +- suporte precisa definir uma senha inicial ou temporária +- a operação é administrativa e explícita + +### Steps + +1. localizar usuário no `auth admin` +2. atualizar a senha por admin +3. validar `login` com a nova senha + +### When Preferred Over Request Reset + +- onboarding assistido +- suporte executivo/manual +- ambiente onde a senha inicial precisa ser entregue explicitamente + +### Success Criteria + +- login com a nova senha funciona +- nenhuma outra tabela é alterada sem necessidade + +--- + +## Playbook 7: Diagnosticar Falha de Acesso + +### Use When + +- o usuário diz “não consigo entrar” +- o acesso Pro não ativa +- não está claro se o problema é auth, buyer ou licença + +### Diagnostic Order + +1. `check-email` +2. `auth admin users` por e-mail +3. `buyers` por e-mail +4. `login` +5. `licenses` por `customer_email` +6. `buyer_validations` por `email` ou `user_id` + +### Better Triage Order In Practice + +Use esta priorização: + +1. `check-email` +2. `login` +3. `auth admin` +4. `buyers` +5. `buyer_validations` +6. `licenses` +7. `activations` + +Motivo: + +- `check-email + login` já classificam a maioria dos casos sem write + +### Interpretation + +- `isBuyer: false` e `hasAccount: false` + - falta conta e falta entitlement +- `isBuyer: false` e `hasAccount: true` + - conta existe, falta buyer +- `isBuyer: true` e login falha por `EMAIL_NOT_VERIFIED` + - buyer ok, falta confirmação de e-mail +- `isBuyer: true` e login falha por credenciais + - entitlement ok, problema é senha + +- `isBuyer: true`, login ok, ativação falha + - sair do escopo de access ops e abrir investigação de licença/activation + +--- + +## Minimal Command/Action Contract For Tasks + +Uma task boa para o squad-creator não deve pedir “investigar como fazer”. + +Ela deve declarar explicitamente: + +1. quais leituras fará primeiro +2. qual write mínimo fará em cada ramo da árvore de decisão +3. qual validação final provará o sucesso +4. quais writes estão proibidos naquele caso + +Exemplo de contrato ruim: + +- “verificar Supabase e resolver acesso” + +Exemplo de contrato bom: + +- “rodar check-email; se hasAccount=false criar auth user; se buyer ausente inserir em buyers; confirmar e-mail por admin apenas se acesso imediato for necessário; validar check-email e login” + +--- + +## Expected Task Outputs + +Toda task gerada a partir deste handoff deve devolver: + +- ação executada +- e-mail alvo +- writes realizados +- estado final de `check-email` +- estado final de `login`, se aplicável +- pendências restantes, se houver + +Também deve devolver: + +- classificação do caso na árvore de decisão +- motivo para cada write executado +- confirmação explícita de que nenhuma activation/seat foi consumida, salvo pedido explícito + +--- + +## Squad-Creator Task Briefs + +### Brief A: Criar Novo Acesso AIOX-Pro + +```md +Criar task operacional para criar um novo acesso AIOX-Pro no backend de licensing já conhecido. + +Contexto fixo: +- serviço: https://aiox-license-server.vercel.app +- projeto Supabase: evvvnarpwcdybxdvcwjh +- oracle de buyer: public.buyers + +A task deve: +- rodar check-email e classificar o caso +- receber email e senha inicial +- criar conta se não existir +- confirmar email por admin quando necessário para liberação imediata +- inserir buyer ativo se ausente +- validar via check-email e login +- listar writes permitidos e proibidos + +Done: +- isBuyer=true +- hasAccount=true +- login 200 +``` + +### Brief B: Liberar Pro Para Conta Existente + +```md +Criar task operacional para liberar entitlement Pro para uma conta já existente no AIOX-Pro. + +Contexto fixo: +- serviço: https://aiox-license-server.vercel.app +- projeto Supabase: evvvnarpwcdybxdvcwjh +- tabela de entitlement: public.buyers + +A task deve: +- confirmar existência da conta +- inserir buyer ativo apenas se ausente +- confirmar e-mail por admin apenas se login falhar por não verificado +- revalidar check-email +- validar login se houver senha fornecida + +Done: +- isBuyer=true +- conta preservada +``` + +### Brief C: Reenviar Verificação + +```md +Criar task operacional para reenviar verificação de email do AIOX-Pro. + +Contexto fixo: +- serviço: https://aiox-license-server.vercel.app +- projeto Supabase: evvvnarpwcdybxdvcwjh + +A task deve: +- confirmar que a conta existe +- chamar resend-verification +- reportar claramente se ainda depende de ação do usuário +- proibir conclusão “resolvido” sem prova de login, salvo se o objetivo explícito for apenas reenvio +``` + +### Brief D: Confirmar E-mail Por Admin + +```md +Criar task operacional para confirmar por admin o email de uma conta do AIOX-Pro. + +Contexto fixo: +- auth backend: Supabase Auth do projeto evvvnarpwcdybxdvcwjh + +A task deve: +- localizar user por email +- aplicar email_confirm=true +- validar login após a confirmação +- explicitar que não deve tocar buyers/licenças se o problema for apenas verificação +``` + +### Brief E: Reset de Senha + +```md +Criar task operacional para reset de senha do AIOX-Pro. + +Contexto fixo: +- serviço: https://aiox-license-server.vercel.app +- página de recovery: https://aiox-license-server.vercel.app/reset-password + +A task deve: +- confirmar existência da conta +- chamar request-reset +- reportar o próximo passo para o usuário +- explicitar que reset não resolve buyer nem verificação de e-mail +``` + +### Brief F: Diagnóstico de Falha de Acesso + +```md +Criar task operacional para diagnosticar por que um usuário não consegue acessar o AIOX-Pro. + +Contexto fixo: +- serviço: https://aiox-license-server.vercel.app +- projeto Supabase: evvvnarpwcdybxdvcwjh +- tabelas relevantes: buyers, buyer_validations, licenses, activations + +A task deve: +- rodar check-email +- testar login cedo para classificar o erro +- verificar auth user +- verificar buyers +- classificar a falha em: falta conta, falta buyer, email não confirmado, senha inválida ou problema de licença +- listar o próximo playbook exato a executar +``` + +--- + +## Definition Of Done + +- task parte do contexto fixo correto +- task não perde tempo redescobrindo projeto/licensing +- task executa apenas o playbook relevante +- task valida no serviço real +- resultado final fica objetivo e auditável + +--- + +_Last Updated: 2026-04-19 | AIOX Ops_ diff --git a/pro b/pro index 8f16e8e4c9..8932e8080f 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 8f16e8e4c9624b91882f05ca66bc9ea9beedbde2 +Subproject commit 8932e8080f4a57ee9aa44e45bf7cdf0c4f467ce2 diff --git a/tests/cli/pro-buyer.test.js b/tests/cli/pro-buyer.test.js new file mode 100644 index 0000000000..00173ca83f --- /dev/null +++ b/tests/cli/pro-buyer.test.js @@ -0,0 +1,273 @@ +/** + * Unit tests for `aiox pro buyer` subcommand module (Story 123.8 — Wave 1). + * + * Covers: + * - Internal helpers (email validation, error classification, batch concurrency, + * email-file parsing) + * - End-to-end subprocess integration against a local mock HTTP server + * + * Wave 2 will add coverage for `register` subcommand and admin-key no-leak. + * + * @see docs/stories/epic-123/STORY-123.8-cohort-buyer-cli-migration.md + * @see AC1, AC2, AC3, AC7, AC10, AC11 + */ + +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +/** + * Run a subprocess asynchronously and capture stdout/stderr + exit code. + * Must be async — spawnSync blocks the event loop which would hang in-process + * mock HTTP servers. + */ +function runAsync(args, env, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const child = spawn('node', args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + const killTimer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Subprocess timeout after ${timeoutMs}ms. stdout=${stdout} stderr=${stderr}`)); + }, timeoutMs); + + child.on('error', (err) => { + clearTimeout(killTimer); + reject(err); + }); + child.on('close', (code) => { + clearTimeout(killTimer); + resolve({ status: code, stdout, stderr }); + }); + }); +} + +const BUYER_MODULE = require('../../.aiox-core/cli/commands/pro/buyer'); +const { + isValidEmail, + classifyError, + parseEmailsFile, + mapWithConcurrency, +} = BUYER_MODULE._internal; + +const AIOX_BIN = path.resolve(__dirname, '..', '..', 'bin', 'aiox.js'); + +describe('Story 123.8 — buyer CLI internals', () => { + describe('isValidEmail', () => { + it.each([ + ['user@example.com', true], + ['a@b.co', true], + ['name.tag+x@host.io', true], + ['no-at-sign', false], + ['two@@at.com', false], + ['missing@domain', false], + ['', false], + [null, false], + [undefined, false], + [{}, false], + ['a'.repeat(260) + '@x.io', false], + ])('isValidEmail(%j) === %j', (input, expected) => { + expect(isValidEmail(input)).toBe(expected); + }); + }); + + describe('classifyError', () => { + it('maps NETWORK_ERROR to exit 2', () => { + const result = classifyError({ code: 'NETWORK_ERROR', message: 'boom' }); + expect(result.exitCode).toBe(2); + expect(result.message).toMatch(/rede/i); + }); + + it('maps RATE_LIMITED with retryAfter to exit 2 with hint', () => { + const result = classifyError({ + code: 'RATE_LIMITED', + message: 'too fast', + details: { retryAfter: 120 }, + }); + expect(result.exitCode).toBe(2); + expect(result.message).toMatch(/120s/); + }); + + it('falls back to default for unknown code', () => { + const result = classifyError({ code: 'XYZ_UNKNOWN', message: 'weird' }); + expect(result.exitCode).toBe(2); + }); + + it('handles null / undefined gracefully', () => { + expect(classifyError(null).exitCode).toBe(2); + expect(classifyError(undefined).exitCode).toBe(2); + }); + }); + + describe('parseEmailsFile', () => { + let tmpFile; + beforeEach(() => { + tmpFile = path.join(os.tmpdir(), `emails-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + }); + afterEach(() => { + if (tmpFile && fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); + }); + + it('parses one email per line, trimming whitespace', () => { + fs.writeFileSync(tmpFile, 'a@x.com\n b@y.com \n\nc@z.com\n'); + expect(parseEmailsFile(tmpFile)).toEqual(['a@x.com', 'b@y.com', 'c@z.com']); + }); + + it('skips comment lines and blank lines', () => { + fs.writeFileSync(tmpFile, '# header\na@x.com\n\n# inline comment\nb@y.com\n'); + expect(parseEmailsFile(tmpFile)).toEqual(['a@x.com', 'b@y.com']); + }); + + it('returns empty array for empty file', () => { + fs.writeFileSync(tmpFile, ''); + expect(parseEmailsFile(tmpFile)).toEqual([]); + }); + }); + + describe('mapWithConcurrency', () => { + it('processes all items and preserves input order', async () => { + const items = [1, 2, 3, 4, 5]; + const results = await mapWithConcurrency(items, 2, async (n) => n * 10); + expect(results).toEqual([10, 20, 30, 40, 50]); + }); + + it('respects concurrency limit (never more than N in-flight)', async () => { + const items = new Array(20).fill(0).map((_, i) => i); + let active = 0; + let peak = 0; + + await mapWithConcurrency(items, 3, async (n) => { + active += 1; + if (active > peak) peak = active; + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return n; + }); + + expect(peak).toBeLessThanOrEqual(3); + expect(peak).toBeGreaterThan(0); + }); + + it('handles empty input without launching workers', async () => { + const results = await mapWithConcurrency([], 5, async () => { + throw new Error('should not be called'); + }); + expect(results).toEqual([]); + }); + + it('caps concurrency at items.length', async () => { + const items = [1]; + const results = await mapWithConcurrency(items, 100, async (n) => n + 1); + expect(results).toEqual([2]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end subprocess integration (AC1, AC2, AC10) +// --------------------------------------------------------------------------- + +describe('Story 123.8 — buyer CLI subprocess E2E', () => { + let server; + let serverUrl; + + function createMockServer(handler) { + return new Promise((resolve) => { + server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + serverUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); + } + + function closeMockServer() { + return new Promise((resolve) => { + if (server) { + server.close(() => resolve()); + server = null; + } else { + resolve(); + } + }); + } + + afterEach(async () => { + await closeMockServer(); + }); + + it('validate --email --json prints stable JSON and exits 0', async () => { + await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: true, + hasAccount: true, + email: 'buyer@example.com', + })); + }); + + const result = await runAsync( + [AIOX_BIN, 'pro', 'buyer', 'validate', '--email', 'buyer@example.com', '--json'], + { ...process.env, AIOX_LICENSE_API_URL: serverUrl }, + ); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed).toEqual({ + email: 'buyer@example.com', + isBuyer: true, + hasAccount: true, + }); + }); + + it('validate --email --json exits 1 with isBuyer=false', async () => { + await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: false, + hasAccount: false, + email: 'stranger@example.com', + })); + }); + + const result = await runAsync( + [AIOX_BIN, 'pro', 'buyer', 'validate', '--email', 'stranger@example.com', '--json'], + { ...process.env, AIOX_LICENSE_API_URL: serverUrl }, + ); + + expect(result.status).toBe(1); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.isBuyer).toBe(false); + }); + + it('validate with invalid email format exits 2 before hitting server', async () => { + const result = await runAsync( + [AIOX_BIN, 'pro', 'buyer', 'validate', '--email', 'not-an-email', '--json'], + process.env, + 10000, + ); + + expect(result.status).toBe(2); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.error).toBe('INVALID_EMAIL'); + }); + + it('register shows pending-Wave-2 message and exits 2', async () => { + const result = await runAsync( + [AIOX_BIN, 'pro', 'buyer', 'register', '--email', 'new@example.com', '--name', 'Novo Comprador', '--yes'], + process.env, + 10000, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/pendente.*Wave 2/i); + }); +}); diff --git a/tests/license/license-api-buyer.test.js b/tests/license/license-api-buyer.test.js new file mode 100644 index 0000000000..6d4c494144 --- /dev/null +++ b/tests/license/license-api-buyer.test.js @@ -0,0 +1,147 @@ +/** + * Unit tests for license-api.js buyer operator methods (Story 123.8 — Wave 1) + * + * Covers validateBuyer() which wraps POST /api/v1/auth/check-email. + * + * @see Story 123.8 — Cohort Buyer CLI Migration + * @see AC1, AC2, AC7, AC10 + */ + +'use strict'; + +const http = require('http'); +const { LicenseApiClient } = require('../../pro/license/license-api'); +const { AuthError } = require('../../pro/license/errors'); + +describe('license-api buyer methods (Story 123.8 — Wave 1)', () => { + let server; + let serverUrl; + + function createMockServer(handler) { + return new Promise((resolve) => { + server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + serverUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); + } + + function closeMockServer() { + return new Promise((resolve) => { + if (server) { + server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + afterEach(async () => { + await closeMockServer(); + }); + + describe('validateBuyer (AC1, AC2)', () => { + it('returns narrow contract { email, isBuyer, hasAccount } for existing buyer', async () => { + await createMockServer((req, res) => { + expect(req.method).toBe('POST'); + expect(req.url).toBe('/api/v1/auth/check-email'); + + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const data = JSON.parse(body); + expect(data.email).toBe('buyer@example.com'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: true, + hasAccount: true, + email: 'buyer@example.com', + })); + }); + }); + + const client = new LicenseApiClient({ baseUrl: serverUrl }); + const result = await client.validateBuyer('buyer@example.com'); + + expect(result).toEqual({ + email: 'buyer@example.com', + isBuyer: true, + hasAccount: true, + }); + // Narrow contract: no extra fields leaked + expect(Object.keys(result).sort()).toEqual(['email', 'hasAccount', 'isBuyer']); + }); + + it('returns isBuyer=false for non-buyer email', async () => { + await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: false, + hasAccount: false, + email: 'stranger@example.com', + })); + }); + + const client = new LicenseApiClient({ baseUrl: serverUrl }); + const result = await client.validateBuyer('stranger@example.com'); + + expect(result.isBuyer).toBe(false); + expect(result.hasAccount).toBe(false); + }); + + it('surfaces hasAccount=false when buyer has no user account yet', async () => { + await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: true, + hasAccount: false, + email: 'newbuyer@example.com', + })); + }); + + const client = new LicenseApiClient({ baseUrl: serverUrl }); + const result = await client.validateBuyer('newbuyer@example.com'); + + expect(result.isBuyer).toBe(true); + expect(result.hasAccount).toBe(false); + }); + + it('throws AuthError on rate-limit (429)', async () => { + await createMockServer((req, res) => { + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 'RATE_LIMITED', + message: 'Too many requests', + retryAfter: 60, + })); + }); + + const client = new LicenseApiClient({ baseUrl: serverUrl }); + await expect(client.validateBuyer('anyone@example.com')).rejects.toBeInstanceOf(AuthError); + }); + + it('does not modify checkEmail response when it contains extra fields (defensive narrowing)', async () => { + // Server could evolve and add fields; validateBuyer must keep contract stable. + await createMockServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + isBuyer: true, + hasAccount: true, + email: 'buyer@example.com', + extraInternalField: 'should-not-leak', + anotherField: 42, + })); + }); + + const client = new LicenseApiClient({ baseUrl: serverUrl }); + const result = await client.validateBuyer('buyer@example.com'); + + expect(Object.keys(result).sort()).toEqual(['email', 'hasAccount', 'isBuyer']); + expect(result).not.toHaveProperty('extraInternalField'); + expect(result).not.toHaveProperty('anotherField'); + }); + }); +});