# TotalSeller Hub Marketplace - Core

Fundação do serviço central do TotalSeller Hub. Este repositório mantém apenas
identidade global, tenants, instâncias, databases por tenant, controle de
acesso e o estado administrativo do provisionamento.

## Separação dos repositórios

- `hub-mkp-core`: schema em inglês, autenticação central, tenants, instâncias,
  databases por tenant, workers e filas administrativas.
- `hub-mkp-app`: aplicação operacional usada pelo cliente. Esse repositório
  continua com o padrão legado `C001/C002/C003/C007/Q001/L001`.
- `totalseller-hub-site`: site comercial e landing page.

Marketplace operacional não pertence ao Core. Não devem existir aqui tabelas,
campos, services ou regras de TikTok, Mercado Livre, Shopee, Amazon, anúncios,
produtos, pedidos, preços, estoque, categorias, sincronização ou filas
operacionais de marketplace.

`worker_task_locks` é uma exceção estrita de infraestrutura: guarda somente
metadados genéricos para coordenar workers entre servidores. Seus campos
`task_type` e `marketplace_code` são identificadores opacos e não armazenam
fila, credencial ou regra operacional.

Exceção arquitetural restrita: o Core pode atuar como broker OAuth
centralizado de marketplaces quando o provedor externo exigir ou favorecer uma
URL única de redirect/callback. Essa exceção cobre somente endpoints de início
de OAuth, callback, validação de estado, troca técnica do `code` por tokens e
entrega servidor a servidor do resultado para a instância correta do `hub-mkp-app`.
O Core não conhece regras de negócio de produto, pedido, estoque, preço,
categoria ou sincronização, não depende diretamente da tabela legada `C003` e
não faz a persistência operacional definitiva dos tokens. A persistência final
continua no projeto dono da integração operacional.

## Schema do Core

As tabelas novas do Core são:

`hub_instances`, `tenants`, `tenant_databases`, `users`, `tenant_users`,
`email_verification_tokens`, `signup_attempts`, `auth_tokens`, `audit_logs`,
`workers`, `jobs`, `worker_task_locks`, `core_migrations`.

O Core não usa mais os nomes legados `C001`, `C002`, `C003`, `C007`, `C008`,
`C009`, `C010`, `C011`, `Q001` e `L001` no código ativo.

## Conceitos centrais

### Tenant

`tenants` representa o grupo do cliente. O Core trata o tenant como unidade
administrativa e de provisionamento.

### Instância

`hub_instances` identifica um servidor do Hub apto a receber tenants. Ela
guarda URLs e dados administrativos necessários para escolher onde um novo
tenant será provisionado.

### Database por tenant

`tenant_databases` relaciona um tenant a uma instância e registra como o Hub
alcançará o database isolado desse tenant. A senha é um segredo reversível e
é protegida com `SecretCipher` antes de ser gravada.

### Usuário global

A senha de usuário existe somente no Core, em `users`, sempre como hash.
Usuários do tenant local são espelhos sem senha e vinculados por `user_uuid`.

### Jobs e workers

`jobs` guarda a fila do Core. `workers` cadastra os executores. O Core é o
plano de controle: entrega jobs, valida a instância, registra o estado e nunca
executa trabalho pesado dentro da requisição HTTP.

## Broker OAuth centralizado da Shopee

O broker OAuth da Shopee existe no Core somente para resolver o problema de
callback centralizado em instalações com múltiplas instâncias do Hub. A Shopee
redireciona a autorização para uma Redirect URL Domain cadastrada no app; por
isso, o callback não deve depender da URL específica de cada servidor do Hub.

Esse broker não torna o Core dono da integração Shopee. Produtos, pedidos,
estoque, preço, categorias, sincronização, regras operacionais de marketplace
e gravação direta na `C003` continuam fora do Core. O Core apenas valida a
origem, mantém uma sessão OAuth temporária, troca o `code` com a Shopee e
entrega o resultado para a instância correta do Hub por chamada assinada
servidor a servidor.

O Core também concentra a assinatura específica da renovação Shopee. O App
continua dono da reserva concorrente e da persistência na C003; o Core recebe o
refresh token somente em chamada interna autenticada, chama a Shopee uma única
vez e devolve o resultado normalizado sem persistir credenciais operacionais.

### Resolução interna do database do tenant

O App pode resolver o database de um callback interno autenticado por:

```http
POST /api/internal/tenant-database-context
Authorization: Bearer HUB_INTERNAL_API_TOKEN
X-Hub-Timestamp: 1760000000
X-Hub-Nonce: nonce-aleatorio-de-uso-unico-com-32-caracteres
X-Hub-Signature: HMAC_SHA256(metodo.rota.timestamp.nonce.corpo)
Content-Type: application/json

{
  "tenant_uuid": "uuid-do-tenant",
  "hub_instance_code": "codigo-da-instancia"
}
```

`hub_instance_code` é obrigatório nos fluxos de callback que já conhecem a
instância. Para autenticação da API pública com um `Seller-Id` vinculado ao
tenant, o App pode enviar somente `tenant_uuid`; nesse caso, o Core resolve a
instância diretamente pelo vínculo único do tenant e mantém as mesmas
validações de tenant, instância e database ativos e provisionados.

O middleware valida bearer, timestamp com janela de 60 segundos e HMAC sobre
`POST`, rota, timestamp, nonce e corpo bruto, nessa ordem e separados por quebra
de linha. O nonce é armazenado somente como hash e só pode ser consumido uma
vez. Somente depois dessas validações o serviço consulta
`tenants`, `hub_instances` e `tenant_databases`, exige tenant, instância e
database provisionados e ativos, confirma que o tenant pertence à instância e
descriptografa a senha com o mesmo `SecretCipher` usado pelo login. A resposta é
exclusivamente servidor a servidor e contém o contrato mínimo de conexão já
usado em `/api/hub/consume-login-token`.

O endpoint não aceita host, nome de database, usuário, senha ou porta enviados
pelo chamador. Essas informações sempre vêm do cadastro administrativo do Core
e não devem ser registradas em logs.

---

### Verificador de `.env` x `.env.example`

O projeto possui um script auxiliar para comparar o arquivo `.env.example` com o `.env` real do ambiente.

Esse script ajuda a identificar:

- variáveis existentes no `.env.example` que ainda não foram definidas no `.env`;
- variáveis existentes no `.env` que não existem mais no `.env.example`;
- variáveis duplicadas;
- linhas inválidas;
- variáveis vazias no `.env`.

O script **não exibe os valores das variáveis**, apenas os nomes, para evitar vazamento de senhas, tokens, chaves de API ou secrets.

Como executar

Na raiz do projeto:

```bash
php scripts/env_diff.php
```
---


### Endpoints esperados

Início do OAuth, chamado pelo Hub/CLI:

```http
GET /api/marketplaces/shopee/oauth/start
```

Parâmetros obrigatórios:

- `integration_id`;
- `tenant_uuid`;
- `user_uuid`;
- `hub_instance_code`;
- `return_url`;
- `environment`, com valores `sandbox` ou `production`;
- `timestamp` e `nonce`;
- `signature`, HMAC-SHA256 da query canônica com o segredo interno.

Callback cadastrado no app da Shopee:

```http
GET /api/marketplaces/shopee/oauth/callback
```

Renovação interna chamada somente pelo App:

```http
POST /api/internal/marketplaces/shopee/token/refresh
Authorization: Bearer HUB_INTERNAL_API_TOKEN
X-Hub-Timestamp: 1760000000
X-Hub-Nonce: nonce-aleatorio-de-uso-unico-com-32-caracteres
X-Hub-Signature: HMAC_SHA256(metodo.rota.timestamp.nonce.corpo)
Content-Type: application/json
```

O middleware valida o corpo bruto antes que o broker aceite tenant, usuário,
instância, integração, ambiente, conta ou refresh token do payload.

A Redirect URL Domain cadastrada na Shopee deve apontar para o domínio público
do Core. A URL completa de callback esperada é:

```text
https://core.example.com/api/marketplaces/shopee/oauth/callback
```

Substitua `https://core.example.com` pelo domínio real configurado em
`APP_URL` e pelas variáveis `SHOPEE_*_REDIRECT_URL`.

### Fluxo de início

O endpoint de start deve:

1. validar a chamada recebida do Hub;
2. validar `environment`;
3. validar instância ativa, tenant pertencente à instância e usuário ativo
   vinculado ao tenant ou com permissão de suporte;
4. rejeitar `return_url` fora da allowlist;
5. gerar state aleatório e persistir somente `state_hash` e `nonce_hash` em
   `oauth_broker_states`, com provider `shopee`, contexto, expiração e consumo;
6. gerar a URL de autorização da Shopee;
7. redirecionar o usuário para a Shopee sem emitir conteúdo antes do header de
   redirect.

URL de autorização da Shopee:

```text
{SHOPEE_AUTH_BASE_URL}
```

Parâmetros:

- `partner_id`;
- `auth_type=seller`;
- `redirect_uri`;
- `response_type=code`;
- `state`.

O início atual não usa `partner_key`, `timestamp` nem `sign` na URL externa.
A Partner Key permanece exclusiva do Core para chamadas assinadas posteriores,
como a troca do código por tokens, e nunca deve aparecer em URL ou log.

### Fluxo de callback

O callback deve:

1. receber `state`, `code` e exatamente um identificador: `shop_id` para
   autorização de loja ou `main_account_id` para autorização de conta principal;
2. consumir atomicamente o hash do `state` em `oauth_broker_states` e rejeitar
   state ausente, expirado, reutilizado ou de outro provider;
3. tratar recusa/cancelamento sem tentar trocar o código;
4. identificar o `environment` salvo no state;
5. trocar o `code`, que é de uso único e válido por dez minutos, por
   `access_token` e `refresh_token` usando a API da Shopee do ambiente escolhido;
6. normalizar a loja recebida no callback ou as listas `shop_id_list` e
   `merchant_id_list` devolvidas para uma conta principal;
7. não salvar tokens como integração operacional definitiva no Core;
8. entregar o resultado para a instância correta do App por chamada
   servidor a servidor assinada pelo Core;
9. redirecionar o usuário para `return_url` com status simples.

A troca usa `POST /api/v2/auth/token/get`. A query contém `partner_id`,
`timestamp` e `sign`; a assinatura é HMAC-SHA256 da concatenação
`partner_id + path + timestamp`, usando a Partner Key. O corpo JSON contém
`code`, `partner_id` e somente `shop_id` ou `main_account_id`. Não há retry
automático: depois de consumir o state ou usar o code, uma falha exige reiniciar
a autorização.

Após entrega bem-sucedida ao Hub:

```text
{return_url}?shopee_auth=success&event_id={event_id}
```

Quando a conta principal devolver várias lojas/contas, o retorno usa
`shopee_auth=account_selection_required`; o Core nunca escolhe a primeira.

Em erro:

```text
{return_url}?shopee_auth=error&error_code={codigo_generico}&event_id={event_id}
```

Nunca coloque na URL de retorno `code`, `access_token`, `refresh_token`,
`partner_key`, payload bruto da Shopee, stack trace ou detalhes sensíveis.
Quando `return_url` vier ausente ou inválida, o Core deve usar um fallback
seguro configurado por ambiente e validado contra a mesma allowlist; esse
fallback não deve apontar para `localhost` em sandbox ou produção.

### Renovação de tokens

A renovação usa `POST /api/v2/auth/access_token/get`. A query contém
`partner_id`, `timestamp` e `sign`, sendo a assinatura HMAC-SHA256 de
`partner_id + path + timestamp` com a Partner Key. O corpo JSON contém
`refresh_token`, `partner_id` e exatamente um identificador compatível:
`shop_id` ou `merchant_id`. `main_account_id` não é convertido em
`merchant_id`.

O broker exige access token, novo refresh token rotativo e `expire_in` na
resposta. Não há retry automático. Timeout, resposta HTTP/JSON inválida,
credenciais ausentes ou conta divergente produzem resultado incerto, pois o
Core não pode provar que o refresh token de uso único deixou de ser consumido.
Erros oficiais de expiração, revogação, desvinculação ou bloqueio são
normalizados como necessidade de reautorização. Nenhum token, assinatura ou
Partner Key entra em URL pública, resposta ao navegador ou log.

### Ambientes Shopee

O `environment` da integração define qual app/base da Shopee será usado.
Valores aceitos:

- `sandbox`;
- `production`.

O ambiente da integração não deve ser amarrado automaticamente ao domínio do
servidor. Isso permite testar `production` de forma controlada em um servidor
de sandbox, desde que as credenciais e allowlists corretas estejam
configuradas e a chamada Hub -> Core esteja assinada.

Variáveis esperadas:

```dotenv
SHOPEE_SANDBOX_PARTNER_ID=
SHOPEE_SANDBOX_PARTNER_KEY=
SHOPEE_SANDBOX_API_BASE_URL=
SHOPEE_SANDBOX_AUTH_BASE_URL=
SHOPEE_SANDBOX_REDIRECT_URL=

SHOPEE_PRODUCTION_PARTNER_ID=
SHOPEE_PRODUCTION_PARTNER_KEY=
SHOPEE_PRODUCTION_API_BASE_URL=
SHOPEE_PRODUCTION_AUTH_BASE_URL=
SHOPEE_PRODUCTION_REDIRECT_URL=

HUB_OAUTH_BROKER_SECRET=
HUB_MKP_APP_URL=
SHOPEE_OAUTH_FALLBACK_RETURN_URL=
SHOPEE_OAUTH_DEFAULT_RETURN_PATH=
SHOPEE_OAUTH_ALLOWED_RETURN_URLS=
SHOPEE_OAUTH_ALLOWED_RETURN_HOSTS=
SHOPEE_OAUTH_HUB_CALLBACK_URL=
SHOPEE_OAUTH_STATE_TTL_SECONDS=
SHOPEE_OAUTH_REQUEST_TOLERANCE_SECONDS=
SHOPEE_OAUTH_HTTP_TIMEOUT_SECONDS=
```

Também deve existir configuração das instâncias Hub permitidas, contendo:

- `hub_instance_code`;
- base URL permitida;
- domínios ou URLs de retorno permitidos;
- callback URL interno para entrega do resultado;
- segredo compartilhado ou chave de validação por instância.

Quando essas variáveis forem adicionadas ao `.env.example`, cada uma deve ter
comentário em português e nunca conter segredo real.

### Contrato Hub -> Core

O Hub chama `GET /api/marketplaces/shopee/oauth/start` em nome da integração
local. A chamada deve ser autenticada com HMAC ou token interno servidor a
servidor. Se já existir um padrão interno entre projetos, ele deve ser
reaproveitado; se não existir, o contrato mínimo é:

- headers com identificador da instância, timestamp e assinatura;
- assinatura HMAC-SHA256 sobre método, path, query canônica e timestamp;
- segredo compartilhado fora do código, preferencialmente por instância;
- janela curta de validade para o timestamp;
- rejeição de replay por nonce quando houver persistência temporária;
- allowlist de `hub_instance_code`, domínio/base URL e `return_url`.

O Core não deve confiar cegamente em qualquer parâmetro vindo da URL. A
instância chamadora precisa estar permitida e a `return_url` deve pertencer a
um domínio ou caminho autorizado para aquela instância.

Para erros de navegação, inclusive assinatura inválida, o fallback deve apontar
para o app Hub, por exemplo:

```dotenv
HUB_MKP_APP_URL=https://sandbox-hub-mkp-app.totalseller.com.br
SHOPEE_OAUTH_DEFAULT_RETURN_PATH=/integracoes
```

O Core não deve usar `APP_URL` como fallback do OAuth Shopee, pois `APP_URL`
representa o próprio Core. No contrato de assinatura Hub -> Core, o
`hub-mkp-app` assina com `CORE_INTERNAL_API_TOKEN`; o `hub-mkp-core` valida com
`HUB_INTERNAL_API_TOKEN`. `HUB_OAUTH_BROKER_SECRET` é aceito apenas como
fallback quando `HUB_INTERNAL_API_TOKEN` não estiver configurado. O valor de
`HUB_INTERNAL_API_TOKEN` no Core precisa ser exatamente igual ao
`CORE_INTERNAL_API_TOKEN` do App.

### Contrato Core -> Hub

Depois da troca do `code`, o Core entrega o resultado para a instância correta
do Hub, por exemplo:

```http
POST {hub_instance_callback_url}/api/internal/marketplaces/shopee/oauth/complete
Content-Type: application/json
```

Payload esperado:

```json
{
  "integration_id": "123",
  "tenant_uuid": "uuid-do-tenant",
  "user_uuid": "uuid-do-usuario",
  "hub_instance_code": "core-local",
  "return_url": "https://app.example.com/oauth/shopee/resultado",
  "start_nonce_hash": "sha256-do-nonce-do-inicio",
  "authorization_subject": {
    "type": "main_account",
    "id": "123456",
    "parameter": "main_account_id"
  },
  "authorized_accounts": [
    {"type": "shop", "id": "987654", "name": null}
  ],
  "access_token": "token",
  "refresh_token": "refresh",
  "token_expires_at": "2026-07-10T12:00:00-03:00",
  "refresh_token_expires_at": "2026-08-09T12:00:00-03:00",
  "authorization_status": "authorized",
  "environment": "sandbox",
  "event_id": "shopee_evento-aleatorio",
  "request_id": "id-sanitizado-da-shopee",
  "created_at": "2026-07-10T12:00:00-03:00"
}
```

Essa chamada deve ser assinada pelo Core para o Hub validar. Tokens nunca
devem passar pelo navegador, query string, logs ou redirect. A gravação final
dos tokens pertence ao `hub-mkp-app`, que é o dono da integração operacional e da
persistência relacionada à `C003`. A entrega repetida com o mesmo `event_id` é
idempotente no App; falha de entrega não reutiliza state nem code e orienta o
usuário a iniciar uma nova autorização.

### Logs seguros

O broker deve registrar códigos técnicos suficientes para diagnóstico, sem
segredos:

- `invalid_state`;
- `missing_code`;
- `invalid_account_context`;
- `expired_state`;
- `invalid_hub_signature`;
- `invalid_return_url`;
- `token_exchange_failed`;
- `delivery_failed`.

Nunca registrar `partner_key`, `access_token` completo, `refresh_token`
completo, `code` completo, payload sensível completo ou stack trace em
resposta pública.

### Como testar

Para sandbox:

1. configure as variáveis `SHOPEE_SANDBOX_*`, `HUB_INTERNAL_API_TOKEN` (ou o
   fallback `HUB_OAUTH_BROKER_SECRET`) e a allowlist da instância Hub de teste;
2. cadastre na Shopee sandbox a Redirect URL Domain do Core e a callback URL
   `/api/marketplaces/shopee/oauth/callback`;
3. inicie o fluxo pelo Hub/CLI usando `environment=sandbox`;
4. confirme que o navegador é redirecionado para a Shopee sandbox;
5. conclua a autorização;
6. verifique que o Core entrega os tokens somente por POST servidor a servidor
   ao Hub e redireciona o usuário para `return_url` com
   `shopee_auth=success`.

Para production controlado:

1. configure as variáveis `SHOPEE_PRODUCTION_*` reais em ambiente protegido;
2. mantenha `environment=production` explícito na chamada do Hub;
3. use allowlist restrita de instância, domínio e `return_url`;
4. valide a assinatura Hub -> Core e Core -> Hub;
5. execute com uma integração de teste conhecida, sem associar o ambiente da
   Shopee ao domínio do servidor automaticamente;
6. confirme que erro retorna apenas `shopee_auth=error`, `error_code` e
   `event_id`, sem tokens ou payload sensível na URL.

## Broker OAuth centralizado do TikTok Shop

O broker OAuth do TikTok Shop segue a mesma fronteira arquitetural da Shopee:
o Core recebe o início público e o callback do provedor, troca o `code` por
tokens, consulta as lojas autorizadas e entrega o resultado para o App por
chamada servidor a servidor. O App continua dono da integração local, da
`C003`, da seleção de múltiplas lojas e da persistência criptografada por
`IntegrationCredentialService`.

O Core não escolhe uma loja quando o TikTok retorna mais de uma autorização.
Nesse caso, ele entrega a lista de lojas ao App para que a seleção continue na
tela operacional.

### Endpoints TikTok

Início do OAuth, chamado pelo App:

```http
GET /api/marketplaces/tiktok/oauth/start
```

Parâmetros esperados:

- `integration_id`, identificador opaco da integração local do App;
- `tenant_uuid`;
- `user_uuid`;
- `hub_instance_code`;
- `return_url`;
- `environment`, com valores `sandbox` ou `production`;
- `timestamp`;
- `nonce`;
- `signature` HMAC da query canônica.

O Core valida assinatura, timestamp, nonce, tenant, usuário, instância Hub,
ambiente e `return_url`. O `integration_id` não é consultado em tabela local
do Core, pois a `C003` pertence ao App.

Callback cadastrado no TikTok Partner Center:

```http
GET /api/marketplaces/tiktok/oauth/callback
```

No sandbox, a URL pública pretendida é:

```text
https://sandbox-hub-mkp-core.totalseller.com.br/api/marketplaces/tiktok/oauth/callback
```

O callback normalmente recebe `code` e `state`. Quando houver recusa ou erro
do provedor, o Core consome o `state`, não troca token e redireciona ao App
com erro sanitizado.

### State persistente TikTok

O TikTok usa a tabela genérica `oauth_broker_states` para guardar somente
metadados do fluxo OAuth. O state puro nunca é salvo: o Core armazena
`state_hash` e `nonce_hash`, além de provider, tenant, usuário, instância Hub,
integração local, ambiente, `return_url`, `event_id`, expiração e consumo.

O state é:

- criptograficamente imprevisível;
- vinculado ao provider `tiktok`;
- vinculado a tenant, usuário, instância, integração, ambiente e `return_url`;
- expirável por `TIKTOK_OAUTH_STATE_TTL_SECONDS`;
- consumido uma única vez no callback, antes de qualquer chamada externa;
- inutilizável após sucesso, falha, expiração ou tentativa de reutilização.

A tabela não guarda tokens, `auth_code`, app secret, seller cipher ou shop
cipher.

### Fluxo TikTok

1. O App monta a chamada de start com `timestamp`, `nonce` e HMAC usando
   `CORE_INTERNAL_API_TOKEN`.
2. O Core valida a chamada com `HUB_INTERNAL_API_TOKEN` ou
   `HUB_OAUTH_BROKER_SECRET`.
3. O Core valida tenant, usuário, instância Hub, ambiente e `return_url`.
4. O Core cria state persistente de uso único.
5. O navegador é redirecionado para `TIKTOK_AUTH_AUTHORIZE_URL` com
   `service_id` e `state`.
6. O TikTok retorna ao Core com `code` e `state`.
7. O Core consome o state.
8. O Core envia o `code` como `auth_code` para
   `{TIKTOK_AUTH_BASE_URL}/api/v2/token/get`, usando `app_key` e
   `app_secret`.
9. O Core chama Get Authorized Shops em
   `{TIKTOK_OPEN_API_BASE_URL}/authorization/202309/shops`, com request
   assinada e `x-tts-access-token`.
10. O Core entrega o resultado ao App por callback interno assinado.
11. O navegador volta ao App apenas com `tiktok_auth`, `event_id` e, em erro,
    `error_code`.

### Contrato Core -> App TikTok

Depois da troca de token e consulta das lojas, o Core chama:

```http
POST {hub_instance_callback_url}/api/internal/marketplaces/tiktok/oauth/complete
Authorization: Bearer HUB_INTERNAL_API_TOKEN
X-Hub-Timestamp: 1760000000
X-Hub-Signature: HMAC_SHA256(timestamp.rawBody)
Content-Type: application/json
```

Payload esperado:

```json
{
  "integration_id": "123",
  "tenant_uuid": "uuid-do-tenant",
  "user_uuid": "uuid-do-usuario",
  "hub_instance_code": "core-local",
  "environment": "sandbox",
  "authorization_status": "authorized",
  "access_token": "token",
  "refresh_token": "refresh",
  "token_expires_at": "2026-07-11T12:00:00-03:00",
  "refresh_token_expires_at": "2026-08-10T12:00:00-03:00",
  "open_id": "open-id",
  "granted_scopes": [],
  "authorized_shops": [
    {
      "shop_id": "7490000000000000000",
      "shop_name": "Loja Exemplo",
      "shop_cipher": "cipher",
      "region": "BR"
    }
  ],
  "event_id": "tiktok_event_id",
  "created_at": "2026-07-11T12:00:00-03:00"
}
```

Tokens e ciphers trafegam somente nessa chamada servidor a servidor. O App
deve validar bearer, timestamp e HMAC sobre o corpo JSON bruto antes de gravar
qualquer dado em `C003`.

### Variáveis TikTok

```dotenv
TIKTOK_SERVICE_ID=
TIKTOK_APP_KEY=
TIKTOK_APP_SECRET=
TIKTOK_OAUTH_CALLBACK_URL=
TIKTOK_AUTH_AUTHORIZE_URL=
TIKTOK_AUTH_BASE_URL=
TIKTOK_OPEN_API_BASE_URL=
TIKTOK_OAUTH_HUB_CALLBACK_URL=
TIKTOK_OAUTH_FALLBACK_RETURN_URL=
TIKTOK_OAUTH_DEFAULT_RETURN_PATH=
TIKTOK_OAUTH_ALLOWED_RETURN_URLS=
TIKTOK_OAUTH_ALLOWED_RETURN_HOSTS=
TIKTOK_OAUTH_STATE_TTL_SECONDS=
TIKTOK_OAUTH_REQUEST_TOLERANCE_SECONDS=
TIKTOK_OAUTH_HTTP_TIMEOUT_SECONDS=
```

Valores esperados no sandbox para as URLs do provider e entrega ao App:

```dotenv
TIKTOK_AUTH_AUTHORIZE_URL=https://partner.tiktokshop.com/open/authorize
TIKTOK_AUTH_BASE_URL=https://auth.tiktok-shops.com
TIKTOK_OAUTH_CALLBACK_URL=https://sandbox-hub-mkp-core.totalseller.com.br/api/marketplaces/tiktok/oauth/callback
TIKTOK_OPEN_API_BASE_URL=https://open-api.tiktokglobalshop.com
TIKTOK_OAUTH_HUB_CALLBACK_URL=https://sandbox-hub-mkp-app.totalseller.com.br/api/internal/marketplaces/tiktok/oauth/complete
TIKTOK_OAUTH_ALLOWED_RETURN_URLS=https://sandbox-hub-mkp-app.totalseller.com.br/integracoes
TIKTOK_OAUTH_ALLOWED_RETURN_HOSTS=sandbox-hub-mkp-app.totalseller.com.br
```

`TIKTOK_SERVICE_ID` identifica o serviço OAuth configurado no TikTok Shop.
`TIKTOK_APP_KEY` identifica o app usado nas APIs. `TIKTOK_APP_SECRET` assina a
troca de token e a consulta inicial de lojas autorizadas; ele nunca deve ser
registrado em logs, respostas públicas ou redirects. O mesmo app key/secret
pode continuar existindo no App quando necessário para chamadas operacionais
TikTok fora deste broker.

No Core, `TIKTOK_APP_SECRET` aceita temporariamente o segredo em texto puro
fornecido pelo Partner Center ou o envelope `enc:v1:` gerado localmente. Quando
o valor começar com `enc:v1:`, o Core usa `HUB_ENCRYPTION_KEY_BASE64` para
descriptografar e envia ao TikTok somente o segredo original. A chave mestra
deve ser Base64 de 32 bytes e nunca deve ser registrada em logs, respostas ou
arquivos versionados.

O App e o Core podem usar chaves mestras diferentes. Quando isso acontecer,
criptografe o mesmo segredo TikTok separadamente em cada projeto: um envelope
`enc:v1:` gerado com a chave do App não deve ser reutilizado no Core, e o
inverso também vale. Reutilize o mesmo ciphertext somente quando as duas
aplicações compartilharem exatamente a mesma `HUB_ENCRYPTION_KEY_BASE64`.

Para gerar um envelope no Core sem passar o segredo por argumento:

```bash
php7.4 bin/encrypt_secret.php
```

Copie o envelope retornado para o `.env` real:

```dotenv
TIKTOK_APP_SECRET=enc:v1:...
```

Para gerar uma chave mestra local:

```bash
php7.4 -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
```

Segredos, chaves mestras e envelopes `enc:v1:` nunca devem entrar no
`.env.example`, documentação versionada ou Git.

### Segurança TikTok

O Core nunca coloca `auth_code`, `access_token`, `refresh_token`,
`app_secret`, `seller_cipher`, `shop_cipher`, assinatura ou Authorization em
query string de retorno ao navegador. Logs devem manter apenas códigos,
`event_id`, status HTTP, campos de configuração ausentes e contexto
sanitizado.

Erros públicos usam códigos como:

- `TIKTOK_INVALID_HUB_SIGNATURE`;
- `TIKTOK_INVALID_RETURN_URL`;
- `TIKTOK_INVALID_STATE`;
- `TIKTOK_EXPIRED_STATE`;
- `TIKTOK_REUSED_STATE`;
- `TIKTOK_MISSING_CODE`;
- `TIKTOK_AUTH_DENIED`;
- `TIKTOK_TOKEN_EXCHANGE_FAILED`;
- `TIKTOK_AUTHORIZED_SHOPS_FAILED`;
- `TIKTOK_DELIVERY_FAILED`.

### Access log do callback TikTok

O callback TikTok pode receber `code` em query string. O vhost Apache do Core
não deve usar um formato que grave `%r` para esse host, pois `%r` inclui a
query completa. Use um formato sem query, por exemplo:

```apache
LogFormat "%h %l %u %t \"%m %U %H\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" no_query_combined
CustomLog ${APACHE_LOG_DIR}/hub-mkp-core-access.log no_query_combined
```

Não apague logs antigos; aplique a mudança no vhost e recarregue o Apache para
que novos callbacks não registrem `code`, `state`, assinatura ou outro valor
sensível na linha de acesso.

### Como testar TikTok em sandbox

Para Development Shop sandbox, o App continua exigindo `Seller_Id` manual, mas
prepara uma pendência curta no Core antes da autorização manual no TikTok Shop
Partner Center:

```http
POST /api/internal/marketplaces/tiktok/sandbox/prepare
Authorization: Bearer HUB_INTERNAL_API_TOKEN
X-Hub-Timestamp: 1760000000
X-Hub-Signature: HMAC_SHA256(timestamp.rawBody)
Content-Type: application/json
```

A pendência fica em `oauth_broker_states` com
`flow_type=development_shop_sandbox` e guarda somente hash SHA-256 normalizado
do `Seller_Id`. O callback sem `state` só é aceito quando o runtime do Core é
`APP_ENV=sandbox`, o `app_key` recebido confere com a configuração, a região é
permitida e o `code` pode ser trocado por token para consultar Get Authorized
Shops. Depois disso o Core correlaciona a pendência por hash de `seller_id` ou
`shop_id`, entrega a conclusão ao App por POST interno assinado e marca
`consumed_at` somente após sucesso do App.

Em Production, o callback sem `state` continua recusado antes da troca do
`code`. O fluxo Production segue pelo start do App, `state` persistente,
callback público, troca de token, entrega interna ao App e retorno sanitizado
ao navegador.

## Requisitos

- PHP 7.4 ou superior
- Composer
- MySQL 5.7+ ou MySQL 8
- extensões PHP `json`, `openssl`, `PDO` e `pdo_mysql`

## Instalação local

```bash
composer install
cp .env.example .env
```

Se o servidor tiver mais de uma versão do PHP e o binário padrão for anterior
ao 7.4, execute o Composer explicitamente com uma versão compatível:

```bash
php7.4 "$(command -v composer)" install
```

Crie o database configurado no `.env`:

```sql
CREATE DATABASE hub_mkp_core
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;
```

Para recriar o banco local de teste do zero, use:

```bash
mysql -u USUARIO -p -e "DROP DATABASE IF EXISTS hub_mkp_core; CREATE DATABASE hub_mkp_core CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
```

Depois execute:

```bash
php7.4 $(which composer) install
php7.4 bin/migrate.php
php7.4 bin/seed.php
php7.4 bin/criar_suporte.php
php7.4 bin/create_worker.php --instance-code="core-local" --name="worker-local-01"
```

Em servidores web, garanta que o usuário do PHP tenha escrita nos logs. Em um
Apache executado como `www-data`, uma opção sem transferir a propriedade e
sem liberar os logs para outros usuários é:

```bash
setfacl -R -m u:"$USER":rwx,u:www-data:rwx,o::--- storage/logs
setfacl -d -m u:"$USER":rwx,u:www-data:rwx,o::--- storage/logs
```

A seed é idempotente e cria/atualiza somente a instância local/default em
`hub_instances`. Ela não cria usuário administrador.

O `.env` real é ignorado pelo Git e nunca deve ser versionado.

## Health check

```http
GET /api/health
```

Para facilitar a verificação pelo navegador, `GET /` responde com o mesmo
health check.

Com o banco conectado:

```json
{
  "success": true,
  "message": "Core online",
  "data": {
    "app": "hub-mkp-core",
    "database": "connected"
  }
}
```

Se a conexão falhar, a API responde com HTTP `503` e um JSON genérico. Detalhes
técnicos são gravados em `storage/logs`; senha, DSN e stack trace não aparecem
na resposta de produção.

## Estrutura

```text
app/
├── controllers/api/   # Controllers HTTP JSON
├── core/              # Bootstrap, roteamento e infraestrutura da aplicação
├── database/
│   ├── migrations/    # Esquema versionado do Core
│   └── seeds/         # Dados administrativos iniciais
├── helpers/           # Funções auxiliares sem regra de negócio
└── routes/            # Registro das rotas
bin/                   # Runners de migration e seed
config/                # Configuração derivada do ambiente
public/                # Front controller HTTP
storage/logs/          # Logs locais não versionados
```

## Tabelas administrativas

- `hub_instances`: instâncias locais do Hub;
- `tenants`: grupos/tenants e estado do provisionamento;
- `tenant_databases`: databases e credenciais administrativas criptografadas;
- `users`: identidades globais, incluindo a flag `is_support`;
- `tenant_users`: vínculos dos usuários comuns com os tenants;
- `email_verification_tokens`, `password_reset_tokens` e `auth_tokens`:
  tokens armazenados somente como hash;
- `signup_attempts`: controle das tentativas públicas de cadastro;
- `jobs` e `workers`: fila administrativa e workers distribuídos;
- `worker_task_locks`: locks globais expiráveis por tarefa/slot;
- `audit_logs`: auditoria sanitizada;
- `core_migrations`: controle das migrations do Core.

Os nomes legados `C001/C002/C003/C007/Q001/L001` não pertencem ao schema
atual do Core. A tabela `C007` continua existindo somente no database de cada
tenant do `hub-mkp-app`, como espelho local sem senha da identidade global do
Core.

## API do worker distribuído

### Cadastro e token

Cada worker pertence a uma C002 e é identificado na tabela `workers` por UUID,
nome e `instance_id`. O token opaco é salvo exclusivamente como hash SHA-256.
O token puro aparece uma única vez na criação ou rotação e deve ser tratado
como segredo:

```bash
php7.4 bin/create_worker.php \
    --instance-code="core-local" \
    --name="worker-local-01"
```

Se o worker já existir, o comando não altera o token. A rotação exige opção
explícita e invalida imediatamente o token anterior:

```bash
php7.4 bin/create_worker.php \
    --instance-code="core-local" \
    --name="worker-local-01" \
    --rotate-token
```

Copie a saída uma única vez para o `.env` local do `hub-mkp-worker`:

```dotenv
CORE_WORKER_TOKEN=TOKEN_EXIBIDO_UMA_VEZ
INSTANCE_CODE=core-local
WORKER_NAME=worker-local-01
```

Nunca grave `CORE_WORKER_TOKEN` no Git, em logs ou em payloads. Tokens de
worker e de usuário usam armazenamentos e middlewares separados; um tipo não
é aceito nos endpoints do outro.

### Heartbeat

```http
POST /api/worker/heartbeat
Authorization: Bearer TOKEN_DO_WORKER
Content-Type: application/json
```

```json
{
  "instance_code": "core-local",
  "worker_name": "worker-local-01",
  "status": "idle",
  "version": "0.1.0",
  "hostname": "servidor-01",
  "php_version": "7.4"
}
```

Instância e nome devem corresponder ao token. O Core atualiza presença, IP,
user agent, status e os metadados permitidos, sem persistir o token.

### Jobs da instância

```http
GET /api/worker/jobs?limit=10
POST /api/worker/jobs/{id}/claim
GET /api/worker/jobs/{id}/execution-context
POST /api/worker/jobs/{id}/complete
POST /api/worker/jobs/{id}/fail
```

Todos exigem Bearer Token de worker. A listagem retorna no máximo 50 jobs
`PENDENTE`, já disponíveis e cujo grupo C001 está atribuído à mesma C002 do
worker. Jobs de grupos ainda sem instância não são expostos por esta API; o
fluxo protótipo atual continua responsável por essa atribuição até a migração
da execução real.

O endpoint `GET /api/worker/jobs/{id}/execution-context` só aceita token de
worker, só responde depois que o próprio worker autenticado assumiu o job e
valida que o tenant pertence à sua instância. Tokens de usuário não autenticam
este endpoint. Para `CRIAR_DATABASE_CLIENTE`, o Core prepara ou reutiliza o
registro em `tenant_databases`, gera a senha segura com `random_bytes()`,
salva a senha criptografada e retorna ao worker o plano de criação física do
database e do usuário MySQL. Para `RODAR_MIGRATIONS_CLIENTE`, ele retorna
tenant, instância e credenciais do database ativo necessárias para o runner
local do `hub-mkp-app`. Para `FINALIZAR_TENANT`, retorna também o primeiro usuário
ADMIN ativo e o CNPJ do tenant para sincronização no database operacional. A
senha descriptografada aparece somente nesta resposta, nunca na listagem de jobs,
endpoints públicos, auditoria ou logs. Em produção, esse endpoint deve
trafegar exclusivamente via HTTPS; HTTP fica restrito a sandbox local/controlado.

Quando `POST /api/worker/jobs/{id}/complete` conclui uma tarefa
`CRIAR_DATABASE_CLIENTE`, o Core marca o job como `CONCLUIDO`, remove qualquer
dado sensível do payload de resposta, atualiza `tenant_databases` e `tenants`
para `DATABASE_CRIADO`, não libera acesso e enfileira
`RODAR_MIGRATIONS_CLIENTE` sem duplicar job pendente ou em processamento. Para
`RODAR_MIGRATIONS_CLIENTE`, o Core atualiza tenant e database para
`MIGRATIONS_EXECUTADAS` somente se ainda estiverem em um status elegível e
enfileira `FINALIZAR_TENANT` sem duplicar job pendente ou em processamento.
Para `FINALIZAR_TENANT`, o Core move o tenant para `PROVISIONADO`, libera
`access_released = 1` e marca o database ativo como `PROVISIONADO`. Depois da
conclusão transacional, o Core tenta uma única vez enviar ao administrador do
tenant um aviso de ambiente pronto, usando `HUB_SITE_URL` para apontar para
`/login/`. Essa tentativa é registrada em
`provisioning_completed_email_attempted_at`; quando o envio tem sucesso,
`provisioning_completed_email_sent_at` também é preenchido. Falha no e-mail
gera log/auditoria, mas não reverte provisionamento, não reabre job e não
agenda retry automático. Em `fail`, o erro sanitizado é gravado no job; falha
de criação move o tenant para `ERRO_PROVISIONAMENTO` e falha de finalização
move o tenant para `FINALIZACAO_ERRO`, sem apagar recursos físicos
automaticamente.

O claim é atômico e somente muda o job de `PENDENTE` para `PROCESSANDO`,
associa `worker_id`, preenche `locked_at` e `started_at`, e incrementa
`attempts`. Complete e fail são aceitos apenas para o worker que fez o claim.
Eles atualizam o estado da Q001, mas não executam regra de provisionamento.

O runtime da Q001 para jobs de worker usa `worker_id`, `attempts`, `locked_at`,
`started_at`, `finished_at` e `last_error`. Payloads retornados são
sanitizados e nenhuma senha, credencial, token ou segredo de database é
exposto.

Conclusão aceita JSON vazio, uma mensagem opcional e, para criação de
database, um objeto `result` sem senha:

```json
{"message":"Concluído pelo worker","result":{"database_name":"hub_mkp_cli_000002","username":"hmkpc000002"}}
```

Falha exige uma mensagem resumida:

```json
{"message":"Erro resumido"}
```

Mensagens são limitadas e conteúdo identificado como sensível é removido.
Ainda não há retry automático nesta API.

### Locks globais de tarefas

Os endpoints abaixo usam o mesmo Bearer Token exclusivo de worker:

```http
POST /api/worker/task-locks/acquire
POST /api/worker/task-locks/heartbeat
POST /api/worker/task-locks/release
```

O lock é identificado de forma determinística por:

```text
INSTANCE_CODE:TASK_TYPE:MARKETPLACE_CODE:SLOT_NUMBER
```

Exemplos:

```text
CORE-LOCAL:STOCK::1
SANDBOX-APP-01:PRICE:SHOPEE:2
```

`marketplace_code` usa string vazia quando não se aplica. Isso evita a
semântica de múltiplos `NULL` em índices únicos do MySQL/MariaDB.

Acquire recebe instância, tarefa, marketplace opcional, slot, proprietário,
PID, hostname e TTL. O Core confirma que instância e proprietário pertencem ao
token autenticado. A operação cria a linha de forma idempotente e, dentro de
transação, bloqueia a chave única com `SELECT ... FOR UPDATE`. O lock é
concedido quando está livre, expirado ou já pertence ao mesmo `locked_by`;
caso contrário, a resposta continua HTTP 200 com `acquired=false`,
`reason=locked` e a expiração atual.

Heartbeat renova somente um lock ainda válido do mesmo proprietário. Um
processo atrasado não ressuscita lock expirado e nunca renova lock já assumido
por outro worker. Release também exige o proprietário atual; ao liberar, limpa
`locked_by`, PID, hostname, `locked_at`, `heartbeat_at` e `lock_expires_at`, e
preserva `released_at`.

O TTL aceita de 30 a 3600 segundos. Quando um processo morre sem liberar,
`lock_expires_at` permite que outro worker assuma a mesma chave depois da
expiração. Esses endpoints coordenam execução, mas não processam estoque,
preço, carga ou qualquer fila operacional.

### Tenants elegíveis para runners operacionais

O Worker autenticado pode consultar os tenants provisionados da própria
instância:

```http
GET /api/worker/operational-tenants?task_type=STOCK&tenant_id=23&limit=1000
Authorization: Bearer TOKEN_DO_WORKER
```

`task_type`, `tenant_id` e `limit` são opcionais. Quando presente,
`task_type` aceita somente `STOCK`, `PRICE`, `PRODUCT_SEND` ou
`PRODUCT_DELETE` e serve apenas para validação e contexto de log; o Core não
consulta fila nem aplica regra operacional de marketplace. `limit` usa 1000
por padrão e aceita no máximo 1000.

A listagem exige tenant e database ativo com status `PROVISIONADO`, acesso
liberado e vínculo com a mesma instância ativa do token do Worker. A resposta
contém somente `tenant_id`, `tenant_uuid`, `tenant_name`, `database_name` e
`instance_code`. Ela não retorna host, usuário ou senha do database, token,
credencial ou outro segredo.

## Autenticação central mínima

### Criar ou atualizar o suporte administrativo

Depois das migrations, execute:

```bash
php7.4 bin/criar_suporte.php \
    --nome="Administrador" \
    --email="admin@totalseller.com.br"
```

A senha e sua confirmação são solicitadas sem exibição quando o terminal
suporta esse recurso. O comando cria ou atualiza de forma idempotente o usuário
global em `users`, marca `is_support = 1`, exige troca da senha inicial e
persiste somente o resultado de `password_hash()`.

Suporte é global no Core e não recebe vínculo artificial em `tenant_users`.
Ao acessar um tenant pelo fluxo Core -> App, o App cria ou atualiza a `C007`
local apenas como espelho da identidade e das permissões, sem copiar a senha.
O caminho antigo `scripts/criar_admin.php` permanece somente como alias
compatível e encaminha para `bin/criar_suporte.php`.

### Login

```bash
curl -X POST https://core.example.com/api/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"admin@totalseller.com.br","password":"SENHA"}'
```

Em caso de sucesso, a resposta contém um token opaco e `expires_at`. O token é
gerado com `random_bytes()` e somente seu hash SHA-256 é salvo em `auth_tokens`.
O tempo padrão é configurado por `AUTH_TOKEN_TTL_MINUTES` e está limitado
entre 1 minuto e 7 dias.

Não é utilizado JWT nesta etapa.

Falhas de login são limitadas temporariamente por IP e por e-mail usando
`signup_attempts` com tipos próprios de autenticação. O bloqueio não é
permanente e retorna mensagem genérica para não revelar se o e-mail existe.

```dotenv
LOGIN_MAX_ATTEMPTS=10
LOGIN_ATTEMPT_WINDOW_MINUTES=15
LOGIN_BLOCK_MINUTES=15
```

O login também retorna `user.is_support` e `password_change_required`. Quando
a troca é obrigatória, o login continua retornando um token opaco, mas essa
sessão pode acessar somente `GET /api/auth/me`,
`POST /api/auth/change-password` e `POST /api/auth/logout`. Outros endpoints
protegidos respondem `403` até a troca ser concluída.

Se o e-mail existir, a senha estiver correta e o cadastro ainda aguardar
confirmação de e-mail, o Core não gera token de sessão e responde `403` com
`code = EMAIL_NOT_CONFIRMED`, mensagem amigável e os campos
`can_resend_at`/`resend_wait_seconds` para a UI controlar o reenvio. Senha
incorreta continua retornando a mensagem genérica de login inválido.

### Usuários criados pelo Hub

O Core continua sendo a origem oficial de autenticação, e-mail, senha e vínculo
com tenants. Quando um administrador cria ou reativa usuários dentro do Hub, o
Hub deve chamar endpoints internos do Core com token servidor a servidor:

```http
POST /api/internal/tenant-users
Authorization: Bearer HUB_INTERNAL_API_TOKEN
Content-Type: application/json
```

Esse endpoint cria ou reutiliza o usuário global por e-mail, cria ou reativa o
vínculo em `tenant_users` e retorna o `user.uuid` para o Hub gravar no espelho
local `C007_UUID`. Se o usuário ainda não possui senha, o Core gera um token de
redefinição, armazena apenas o hash e envia o link pelo fluxo de recuperação de
senha configurado em `HUB_SITE_URL`; senha nunca é enviada por e-mail.

Para ativar ou desativar o acesso do usuário ao tenant sem remover o usuário
global:

```http
POST /api/internal/tenant-users/{user_uuid}/status
Authorization: Bearer HUB_INTERNAL_API_TOKEN
Content-Type: application/json
```

O status altera somente `tenant_users.is_active`. O token
`HUB_INTERNAL_API_TOKEN` deve existir apenas nos ambientes internos e deve ter
o mesmo valor configurado como `CORE_INTERNAL_API_TOKEN` no Hub.

### Consultar o usuário autenticado

```bash
curl https://core.example.com/api/auth/me \
    -H 'Authorization: Bearer TOKEN'
```

`GET /api/auth/me` retorna o usuário global e seus vínculos ativos de
`tenant_users`, incluindo perfil, status e liberação de acesso do tenant em
`tenants`. A resposta inclui ainda `is_support`,
`password_change_required`, `password_changed_at` e `password_max_age_days`.

### Tenants do usuário

```bash
curl https://core.example.com/api/auth/tenants \
    -H 'Authorization: Bearer TOKEN'
```

`GET /api/auth/tenants` retorna os ambientes vinculados ao usuário autenticado,
incluindo ambientes ainda em preparação. Usuários comuns enxergam apenas seus
vínculos ativos em `tenant_users`; suporte pode enxergar ambientes conforme a
regra administrativa vigente. A listagem inclui a instância Hub quando já
atribuída e nunca retorna senha, token, credencial de database, payload interno
de job ou detalhe técnico sensível.

Cada item traz `can_enter`, que indica se o acesso ao Hub já pode ser liberado.
Quando `can_enter=false`, a UI deve usar `provisioning.label`,
`provisioning.progress`, `provisioning.has_error` e
`provisioning.error_message` para exibir um status amigável de preparação.
Exemplo resumido:

```json
{
  "id": 3,
  "uuid": "uuid-do-tenant",
  "name": "Grupo Exemplo",
  "cnpj": "27865757000102",
  "status": "PENDENTE_PROVISIONAMENTO",
  "access_released": false,
  "can_enter": false,
  "hub_instance": {
    "id": 1,
    "code": "core-local",
    "login_url": "https://hub.example.com"
  },
  "provisioning": {
    "current_operation": "CRIAR_DATABASE_CLIENTE",
    "label": "Criando banco de dados",
    "progress": 25,
    "has_error": false,
    "error_message": null
  }
}
```

Para ambientes prontos, `can_enter=true`, `access_released=true` e
`provisioning.label` retorna `Ambiente pronto`.

### Token temporário de entrada no Hub

```bash
curl -X POST https://core.example.com/api/auth/tenants/2/login-token \
    -H 'Authorization: Bearer TOKEN'
```

Esse endpoint gera um token temporário, de uso único, para entrar no Hub.
Somente o token puro aparece na resposta, junto com a URL de entrada e a data
de expiração. O token é armazenado somente como hash em `tenant_login_tokens`.
Não registre o token em logs, auditoria ou histórico do navegador.

### Consumo do token pelo Hub

```bash
curl -X POST https://core.example.com/api/hub/consume-login-token \
    -H 'Content-Type: application/json' \
    -d '{"token":"TOKEN_PURO"}'
```

O Hub chama esse endpoint no servidor para validar o token, obter os dados do
usuário, do tenant e do database, e criar a sessão local sem expor senhas ao
navegador. O token é marcado como usado em transação e não pode ser reutilizado.
A resposta servidor a servidor inclui `tenant.id`, `tenant.uuid`,
`hub_instance`, `database.id`, `database.database_name`, host, porta, usuário e
a senha necessária para a conexão do tenant. Essa senha não deve ser retornada
ao navegador nem registrada em logs.

### Política e troca de senha

`users` mantém três campos de segurança com nomes convencionais:

- `is_support`: identifica suporte administrativo do Core;
- `must_change_password`: força a próxima troca;
- `password_changed_at`: registra quando a senha atual foi definida.

A validade não é persistida. Ela é calculada por
`password_changed_at + PASSWORD_MAX_AGE_DAYS`, com padrão de 90 dias. Valor
nulo, data inválida, prazo vencido ou `must_change_password = 1` exige troca.
Senhas escolhidas no cadastro público ou no comando de administrador recebem
`password_changed_at` no momento da criação. Contas antigas sem essa data
precisam trocá-la no próximo login.

```dotenv
PASSWORD_MAX_AGE_DAYS=90
```

Para trocar a senha autenticada:

```bash
curl -X POST https://core.example.com/api/auth/change-password \
    -H 'Authorization: Bearer TOKEN' \
    -H 'Content-Type: application/json' \
    -d '{
      "current_password":"SENHA_ATUAL",
      "new_password":"NOVA_SENHA_SEGURA",
      "new_password_confirmation":"NOVA_SENHA_SEGURA"
    }'
```

A nova senha deve ter pelo menos 10 caracteres, incluindo letra maiúscula,
letra minúscula, número e caractere especial; também deve conferir com a
confirmação e ser diferente da atual. A operação atualiza o hash, limpa a
exigência, registra a data, revoga todas as outras sessões do usuário e audita
o evento sem registrar senha ou token.

### Recuperação de senha

O Core expõe dois endpoints públicos para recuperação de senha. A senha oficial
continua existindo somente no Core.

```bash
curl -X POST https://core.example.com/api/auth/password/forgot \
    -H 'Content-Type: application/json' \
    -d '{"email":"usuario@exemplo.com"}'
```

`POST /api/auth/password/forgot` sempre retorna uma mensagem genérica. Quando
o e-mail pertence a um usuário ativo, o Core gera um token opaco, salva somente
seu hash em `email_verification_tokens` com `type = RESET_PASSWORD` e envia um
link para o site definido em `HUB_SITE_URL`.

```bash
curl -X POST https://core.example.com/api/auth/password/reset \
    -H 'Content-Type: application/json' \
    -d '{
      "token":"TOKEN_PURO",
      "password":"NOVA_SENHA_SEGURA",
      "password_confirmation":"NOVA_SENHA_SEGURA"
    }'
```

`POST /api/auth/password/reset` valida o token, expiração e uso único, atualiza
`users.password_hash`, define `password_changed_at`, limpa
`must_change_password`, marca o token como usado e revoga sessões antigas do
usuário. Senha e token puro não são registrados em auditoria.

```dotenv
PASSWORD_RESET_TOKEN_TTL_MINUTES=30
HUB_SITE_URL=http://sandbox-hub-mkp.totalseller.com.br
```

### Usuários de suporte

Suporte é uma flag simples controlada exclusivamente pelo Core por
`is_support = 1`. Pode haver mais de um usuário, sem níveis ou perfis
complexos nesta etapa. O suporte não precisa de vínculo em `tenant_users`.
Quando ele acessa um tenant, o fluxo Core para App transmite `is_support = true`
e o App mantém esse indicador apenas no espelho local `C007`, sem copiar a
senha.

Configure os padrões locais:

```dotenv
SUPPORT_DEFAULT_EMAIL=ti@totalseller.com.br
SUPPORT_DEFAULT_NAME=Suporte
```

Depois das migrations, crie ou atualize um suporte:

```bash
php7.4 bin/criar_suporte.php
php7.4 bin/criar_suporte.php \
    --email="claudio@totalseller.com.br" \
    --nome="Claudio"
```

A senha inicial é solicitada sem exibição, deve seguir a mesma política forte
do Core e sempre exige troca no primeiro login. Para automação pontual, ela
também pode ser passada pelo ambiente do processo:

```bash
SUPPORT_INITIAL_PASSWORD='senha-temporaria-segura' \
    php7.4 bin/criar_suporte.php
```

`SUPPORT_INITIAL_PASSWORD` é temporária e não deve ser adicionada ao `.env`.
Ao atualizar um suporte existente, o comando não duplica o e-mail, redefine a
senha e a troca obrigatória, ativa a conta e revoga sessões abertas.

O suporte autenticado e com senha vigente pode pesquisar tenants:

```http
GET /api/support/tenants?search=grupo&status=PROVISIONADO&instance=core-local&limit=20&page=1
```

`limit` usa 20 por padrão e aceita no máximo 100. A pesquisa cobre nome e CNPJ
do tenant, e-mail do primeiro vínculo ADMIN ativo, nome do database ativo e
código da instância. A resposta não contém senha de database, credencial ou
token. Usuários comuns recebem `403`; acessos permitidos e negados são
auditados em `audit_logs`.

### Logout

```bash
curl -X POST https://core.example.com/api/auth/logout \
    -H 'Authorization: Bearer TOKEN'
```

O logout preenche `auth_tokens.revoked_at`; a sessão não é apagada. Tokens
revogados, expirados ou pertencentes a usuários inativos recebem HTTP `401`.
Login válido, tentativa inválida e logout geram auditoria em `audit_logs`,
sem senha nem token puro.

Em produção, credenciais e Bearer Tokens devem trafegar exclusivamente por
HTTPS. HTTP não oferece proteção adequada para autenticação real.

## Cadastro público inicial

O Core não possui interface visual. Um site poderá consumir estas APIs
futuramente, mas nenhuma integração com `hub-mkp` ou com o site foi criada
nesta etapa.

### Configuração

```dotenv
PUBLIC_SIGNUP_ENABLED=true
PUBLIC_SIGNUP_REQUIRE_EMAIL_CONFIRMATION=true
PUBLIC_EMAIL_CONFIRMATION_TTL_MINUTES=1440
EMAIL_CONFIRMATION_RESEND_COOLDOWN_SECONDS=60

SIGNUP_LIMIT_IP_PER_HOUR=5
SIGNUP_LIMIT_EMAIL_PER_DAY=3
SIGNUP_LIMIT_CNPJ_PER_DAY=3

CAPTCHA_ENABLED=false
CAPTCHA_PROVIDER=turnstile
CAPTCHA_TURNSTILE_SECRET_KEY=
CAPTCHA_TIMEOUT_SECONDS=10

MAIL_ENABLED=false
MAIL_FROM_EMAIL=no-reply@totalseller.com.br
MAIL_FROM_NAME="TotalSeller Hub Marketplace"
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
```

`APP_KEY` é obrigatória para cadastro com confirmação e deve conter 32 bytes
aleatórios em base64. Ela assina os hashes HMAC dos tokens; sua troca invalida
confirmações ainda pendentes.

Com `CAPTCHA_ENABLED=false`, nenhum token de captcha é exigido. Quando
habilitado, o cadastro público deve enviar `captcha_token` para o Core validar
no provedor configurado. O provedor inicial suportado para produção é
Cloudflare Turnstile; provider e segredo inválidos fazem o cadastro falhar de
forma fechada com mensagem pública segura.

O cadastro público também valida e normaliza `mobile_phone`. O valor salvo em
`users.mobile_phone` contém apenas números, exige 11 dígitos, DDD e número
celular começando com `9`. A senha do usuário continua existindo somente no
Core e a política forte é validada no backend em cadastro, troca obrigatória e
redefinição de senha.

Com `MAIL_ENABLED=false` em ambiente diferente de produção, o Core simula a
entrega gravando a mensagem em `storage/logs/mail.log`. Esse arquivo funciona
como uma caixa de e-mail de desenvolvimento e contém o token puro, portanto
deve manter acesso restrito. Em produção, mail desabilitado provoca rollback
do cadastro em vez de criar uma conta sem entregar a confirmação.

Quando `MAIL_ENABLED=true`, o envio SMTP usa PHPMailer e as configurações
`MAIL_*`.

### Status

```http
GET /api/public/signup/status
```

A resposta informa se o cadastro, a confirmação de e-mail e o captcha estão
habilitados.

### Registrar

```bash
curl -X POST https://core.example.com/api/public/register \
    -H 'Content-Type: application/json' \
    -d '{
      "name": "Tony",
      "email": "tony@example.com",
      "password": "Senha@12345",
      "password_confirmation": "Senha@12345",
      "tenant_name": "Grupo Exemplo",
      "cnpj": "27865757000102",
      "mobile_phone": "41999998888"
    }'
```

O endpoint valida e normaliza os dados, incluindo senha forte, CNPJ com 14
dígitos e dígitos verificadores válidos, e celular brasileiro com DDD. O
celular é salvo normalizado em `users.mobile_phone`, somente com números.
Também registra tentativas em `signup_attempts` e aplica limites por IP,
e-mail e CNPJ. E-mail e CNPJ não podem ser repetidos. O tenant é criado em
`tenants`, o usuário global em `users`, o vínculo em `tenant_users` e o token
de confirmação em
`email_verification_tokens`.

O token nunca é retornado pela API. Quando a confirmação por e-mail está
habilitada, o Core envia um link para o site configurado em `HUB_SITE_URL` no
formato `/confirmar-email/?token=...`; sem essa variável configurada, o envio
falha de forma clara e o cadastro é revertido.

### Reenviar confirmação

```bash
curl -X POST https://core.example.com/api/public/resend-confirmation \
    -H 'Content-Type: application/json' \
    -d '{"email":"usuario@exemplo.com"}'
```

O endpoint retorna mensagem genérica para não revelar se o e-mail existe ou se
já foi confirmado. Quando há uma conta pendente, ele invalida confirmações
pendentes antigas, gera um novo token opaco, salva somente o hash e envia um
novo link para `HUB_SITE_URL/confirmar-email/?token=...`.

O reenvio respeita `EMAIL_CONFIRMATION_RESEND_COOLDOWN_SECONDS`. Durante o
cooldown, a resposta usa `code = EMAIL_CONFIRMATION_COOLDOWN` e inclui
`can_resend_at` e `resend_wait_seconds`. O endpoint reutiliza limites
antiabuso por IP e e-mail em `signup_attempts`.

### Confirmar e-mail

```bash
curl -X POST https://core.example.com/api/public/confirm-email \
    -H 'Content-Type: application/json' \
    -d '{"token":"TOKEN_RECEBIDO"}'
```

Uma confirmação válida ativa `users` e `tenant_users`, atribui uma instância
padrão ativa ao tenant, move o tenant para `PENDENTE_PROVISIONAMENTO` e cria
em `jobs` uma tarefa `CRIAR_DATABASE_CLIENTE`.

A confirmação apenas enfileira o trabalho; nenhum database é criado durante a
requisição HTTP. O worker processa a tarefa posteriormente, em modo simulado
por padrão ou em modo real quando habilitado localmente e solicitado na CLI.
Tokens usados, expirados ou inválidos são recusados.

Se `PUBLIC_SIGNUP_REQUIRE_EMAIL_CONFIRMATION=false`, usuário e vínculo são
ativados e a tarefa de provisionamento é criada já durante o cadastro.

Cadastro recebido, bloqueio antiabuso, falha de captcha, criação pendente,
entrega da confirmação e resultado da confirmação são auditados em
`audit_logs`. Senha e token puro nunca entram nessa tabela.

## Reset seguro de tenant de teste

O comando `bin/reset_tenant_teste.php` remove um tenant de teste e os registros
relacionados para permitir repetir fluxos de cadastro e provisionamento com o
mesmo e-mail/CNPJ. Ele é destrutivo e não deve ser habilitado em produção.

Travas obrigatórias:

- `APP_ENV=production` sempre bloqueia o comando;
- `ALLOW_TENANT_TEST_RESET=true` precisa estar configurado no ambiente local;
- sem `--execute`, o comando roda somente em dry-run;
- execuções reais exigem `--confirm`;
- databases físicos só podem ser removidos com `--drop-database`;
- usuários MySQL só podem ser removidos com `--drop-user`;
- nomes físicos são validados antes de qualquer `DROP`.

Variável local:

```dotenv
ALLOW_TENANT_TEST_RESET=false
```

Dry-run por tenant:

```bash
php7.4 bin/reset_tenant_teste.php --tenant-id=2
```

Dry-run por e-mail ou CNPJ:

```bash
php7.4 bin/reset_tenant_teste.php --email=cliente.teste@totalseller.com.br
php7.4 bin/reset_tenant_teste.php --cnpj=27865757000102
```

Execução removendo apenas registros do Core:

```bash
php7.4 bin/reset_tenant_teste.php \
    --tenant-id=2 \
    --execute \
    --confirm=RESET-TENANT-2 \
    --core-only
```

Execução removendo também database e usuário MySQL do tenant:

```bash
php7.4 bin/reset_tenant_teste.php \
    --tenant-id=2 \
    --execute \
    --confirm=RESET-TENANT-2 \
    --drop-database \
    --drop-user
```

Para seleção por e-mail ou CNPJ, a confirmação é:

```bash
--confirm=RESET-TENANT
```

Se houver job `PROCESSANDO`, o comando aborta. Use `--force` somente quando o
worker estiver parado e o reset manual for intencional.

Por segurança, o comando só remove database físico com nome no padrão
`hub_mkp_cli_[0-9]+` e usuário MySQL no padrão `hmkpc[0-9]+`. Para casos
excepcionais, existem `--allow-custom-database-name` e
`--allow-custom-database-user`, mantendo validação de caracteres seguros.

## Provisionamento por worker

O worker processa somente tarefas disponíveis com:

- `jobs.status = PENDENTE`;
- `jobs.operation = CRIAR_DATABASE_CLIENTE`;
- `jobs.available_at` menor ou igual ao horário atual.

Prioridade maior é processada primeiro; em empate, prevalece a tarefa mais
antiga. Cada execução deve informar sua instância por `--instancia` ou usar
`CORE_INSTANCE_CODE`. Tenants atribuídos a outra instância nunca são
processados; tenants sem instância somente são assumidos pela instância
padrão ativa.

### Modo simulado

Este é o modo padrão e continua disponível para desenvolvimento:

```bash
php7.4 bin/provisionar.php
php7.4 bin/provisionar.php --limit=5
php7.4 bin/provisionar.php --grupo=7
php7.4 bin/provisionar.php --instancia=core-local
```

O limite padrão é 5 e aceita valores entre 1 e 100. `--grupo` restringe o lote
a um tenant específico. A simulação atribui a instância, move o tenant para
`PROVISIONAMENTO_SIMULADO`, conclui `jobs` e registra os eventos em
`audit_logs`. Ela não cria database ou usuário MySQL.

Também é possível usar o atalho:

```bash
php7.4 "$(command -v composer)" provision
```

### Modo real

O modo real possui duas travas obrigatórias: a variável local
`PROVISIONING_REAL_ENABLED=true` e a opção explícita `--real`.

Cada servidor do Hub deve manter em seu próprio `.env`:

```dotenv
CORE_INSTANCE_CODE=core-local
PROVISIONING_REAL_ENABLED=false

PROVISION_DB_HOST=127.0.0.1
PROVISION_DB_PORT=3306
PROVISION_DB_ADMIN_USER=
PROVISION_DB_ADMIN_PASSWORD=
PROVISION_DB_NAME_PREFIX=hub_mkp_cli_
PROVISION_DB_USER_PREFIX=hmkpc
PROVISION_DB_USER_HOST=localhost
PROVISION_DB_CHARSET=utf8mb4
PROVISION_DB_COLLATION=utf8mb4_unicode_ci
```

As credenciais administrativas são locais ao servidor que executa o worker.
Elas não são gravadas no Core, em C002, C003, Q001 ou L001, e nunca devem ser
versionadas. Campos administrativos legados de C002 não são usados por este
fluxo e devem permanecer sem credenciais.

Use uma conta MySQL dedicada, limitada ao servidor de provisionamento. Ela
precisa criar database e usuário e delegar ao usuário do cliente somente:
`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `ALTER`, `INDEX`, `DROP` e
`REFERENCES` no database criado. Não use a conta da aplicação Core nem conceda
privilégios como `SUPER`, `FILE` ou `PROCESS`.

Depois de revisar a configuração local:

```bash
php7.4 bin/provisionar.php --dry-run --real --instancia=core-local
php7.4 bin/provisionar.php --real --instancia=core-local --limit=5
php7.4 bin/provisionar.php --real --instancia=core-local --grupo=7
```

No fluxo distribuído atual, a criação física não é executada pelo Core. O Core:

1. lista e permite claim apenas de jobs da instância do worker;
2. prepara o plano de `CRIAR_DATABASE_CLIENTE` em `tenant_databases`;
3. gera a senha do usuário do tenant e salva somente a versão criptografada;
4. entrega a senha descriptografada apenas no execution-context do job claimado;
5. recebe o `complete` do worker sem senha no payload;
6. move tenant e database para `DATABASE_CRIADO`, sem liberar acesso;
7. cria, na mesma transação, a tarefa `RODAR_MIGRATIONS_CLIENTE`.

O worker da instância cria database e usuário MySQL, aplica grants e testa a
conexão. Seeds, finalização e liberação de acesso pertencem a etapas
posteriores.

O Core entrega ao worker somente `charset=utf8mb4` e
`collation=utf8mb4_unicode_ci` para essa criação. Valores diferentes na
configuração de provisionamento são recusados, evitando que um tenant novo
seja criado com o default do servidor.

### Dry-run

```bash
php7.4 bin/provisionar.php --dry-run
php7.4 bin/provisionar.php --dry-run --grupo=7 --limit=5
php7.4 bin/provisionar.php --dry-run --real --instancia=core-local
```

O dry-run apenas lista tarefas elegíveis para o modo e a instância informados.
Ele não altera Q001, C001 ou L001 e não conecta ao MySQL de provisionamento.
Por isso, o dry-run real pode ser usado antes de habilitar
`PROVISIONING_REAL_ENABLED`.

### Fluxo da simulação

Cada tarefa é relida com `SELECT ... FOR UPDATE` e marcada como
`PROCESSANDO`, com hostname/PID do worker. Depois o worker:

1. exige grupo em `PENDENTE_PROVISIONAMENTO`;
2. escolhe a primeira instância C002 padrão, ativa e com status `ATIVA`;
3. calcula um nome como `hub_mkp_cli_000007`;
4. muda o grupo para `PROVISIONAMENTO_SIMULADO`, sem liberar acesso;
5. conclui Q001 e grava em `Q001_Payload_Resposta` o modo, grupo, instância,
   nome simulado e mensagem;
6. registra início, instância, database simulado e conclusão em L001.

Nenhuma linha é criada em C003, nenhum comando `CREATE DATABASE` é executado e
não há conexão com servidores externos.

### Retry e falhas parciais

Quando uma tarefa falha, `attempts` é incrementado. Enquanto houver
tentativas, ela retorna a `PENDENTE` após o intervalo configurado por:

```dotenv
PROVISIONING_RETRY_DELAY_MINUTES=5
```

Ao atingir `Q001_Max_Tentativas`, a tarefa muda para `ERRO`. Falhas de uma
tarefa não interrompem as seguintes. O comando termina com código diferente
de zero quando o lote produz retry ou erro, permitindo monitoramento por cron.

Comandos DDL do MySQL não participam da transação do Core. Se o database ou o
usuário já existir sem C003 ativo, ou se uma falha ocorrer depois da criação
externa, o worker falha de forma fechada e registra um código que exige
intervenção manual. Ele não reutiliza nem remove automaticamente recursos
parciais, evitando apagar dados fora do controle transacional.

Senhas não aparecem no retorno da CLI, Q001, L001 ou API. Ainda assim, a
configuração de logs e auditoria do próprio MySQL deve ser revisada, pois
comandos administrativos podem ser registrados pelo servidor ou por clientes.

Quando o teste de conexão do usuário recém-criado falha, Q001 recebe SQLSTATE,
código e mensagem técnica sanitizada. O evento `PROVISIONING_REAL_FAILED` em
L001 registra também database, usuário, host, porta e `user_host`, sem incluir
a senha.

## Migrations do database do tenant

O Core não conhece o schema operacional do Hub e não executa migrations no
modelo distribuído. Para um grupo em `DATABASE_CRIADO`, o Core apenas mantém a
tarefa `RODAR_MIGRATIONS_CLIENTE` em Q001, fornece contexto ao worker
autenticado e registra o resultado reportado por ele. Quem chama o runner é o
`hub-mkp-worker` instalado na instância do tenant:

```bash
php7.4 /caminho/do/hub-mkp-app/bin/tenant_migrate.php
```

O runner pertence ao `hub-mkp-app` e usa suas migrations oficiais. A senha do
database só é entregue ao worker pelo endpoint de contexto e, no worker, só
deve ser repassada ao processo filho pela variável de ambiente
`TENANT_DB_PASSWORD`, nunca como argumento de linha de comando.

### Configuração local da instância

```dotenv
HUB_MKP_PATH=/var/www/hub-mkp-app
HUB_MKP_TENANT_MIGRATE_SCRIPT=bin/tenant_migrate.php
PHP_CLI_BINARY=php7.4

TENANT_MIGRATIONS_ENABLED=false
TENANT_MIGRATIONS_TIMEOUT_SECONDS=300
```

`HUB_MKP_PATH` deve apontar para o clone local do `hub-mkp-app` na mesma instância.
O diretório, o caminho relativo do runner e o binário PHP são validados antes
da execução. O modo real exige `TENANT_MIGRATIONS_ENABLED=true`; o timeout é
limitado entre 1 e 3600 segundos.

### Enfileirar um grupo existente

O provisionamento novo enfileira migrations automaticamente. Para um grupo
antigo que já possua C003 ativa:

```bash
php7.4 bin/migrar_tenant.php --enqueue --grupo=18
```

O comando dedicado continua disponível:

```bash
php7.4 bin/enfileirar_migrations_tenant.php --tenant-id=18
```

`--grupo=18` continua aceito como alias compatível.

O enqueue exige C001 e C003 ativa em `DATABASE_CRIADO`,
`MIGRATIONS_EXECUTADAS` ou `PROVISIONADO`. Isso permite aplicar novas
migrations também em tenants já liberados quando o `hub-mkp-app` for atualizado.
Tarefas anteriores em `CONCLUIDO` ou `ERRO` não bloqueiam uma nova execução;
somente uma tarefa `PENDENTE` ou `PROCESSANDO` da operação
`RODAR_MIGRATIONS_CLIENTE` evita duplicidade.

### Verificar e executar no modelo legado/protótipo

```bash
php7.4 bin/migrar_tenant.php \
    --dry-run \
    --grupo=18 \
    --instancia=core-local

php7.4 bin/migrar_tenant.php \
    --grupo=18 \
    --instancia=core-local \
    --limit=1
```

O `--dry-run` chama o runner do `hub-mkp-app` também com `--dry-run`, captura sua
saída e não altera C001, C003, Q001 ou L001. A execução real:

1. assume Q001 com bloqueio transacional;
2. valida grupo, C003 ativa e instância C002;
3. descriptografa a senha apenas em memória;
4. inicia o runner com `proc_open()`, sem shell e sem segredo no comando;
5. captura código de saída, duração, stdout e stderr;
6. sanitiza toda saída antes de persistir ou exibir;
7. move C001 e C003 para `MIGRATIONS_EXECUTADAS`;
8. conclui Q001 e mantém `C001_Flag_Acesso_Liberado = N`.

O runner oficial é chamado também quando C001/C003 já estão em
`MIGRATIONS_EXECUTADAS`: se não houver migration pendente, ele conclui sem
erro; se houver, aplica normalmente.

Falhas movem C001 e C003 para `MIGRATIONS_ERRO` e usam o mesmo intervalo de
retry de `PROVISIONING_RETRY_DELAY_MINUTES`. Q001 volta a `PENDENTE` enquanto
houver tentativas e termina em `ERRO` ao esgotá-las. Uma falha não interrompe
as tarefas seguintes. Senha em texto puro ou criptografada nunca entra em
argumentos, terminal, payloads ou logs. No modelo distribuído novo, a execução
real deve ser feita pelo `hub-mkp-worker`; mantenha o comando legado apenas
para verificações controladas enquanto a migração operacional estiver em
andamento.

## Finalização do tenant

Depois das migrations, o Core enfileira `FINALIZAR_TENANT` para o worker da
instância. O worker chama o runner oficial `bin/tenant_finalize.php` do
`hub-mkp-app`. Ele cria ou atualiza a C007 local como espelho do primeiro vínculo
ADMIN ativo do grupo, identificado por `C007_UUID`. A senha do usuário
permanece exclusivamente no Core e nunca é enviada ou gravada no tenant.
O Core também passa `TENANT_GROUP_CNPJ` para que o Hub garanta a empresa
inicial `C004` com nome e CNPJ do tenant.

Configure cada instância:

```dotenv
HUB_MKP_PATH=/var/www/hub-mkp-app
HUB_MKP_TENANT_FINALIZE_SCRIPT=bin/tenant_finalize.php
PHP_CLI_BINARY=php7.4

TENANT_FINALIZE_ENABLED=false
TENANT_FINALIZE_TIMEOUT_SECONDS=300
```

O caminho do Hub, o runner e o binário PHP são validados antes da execução.
A finalização real exige `TENANT_FINALIZE_ENABLED=true`; o dry-run permanece
disponível para validar conexão, schema e ação prevista sem alterar o Core ou
o database do tenant.

### Enfileirar manualmente para teste

```bash
php7.4 bin/enfileirar_finalizacao_tenant.php --tenant-id=18
php7.4 bin/finalizar_tenant.php --enqueue --grupo=18
```

### Executar no modelo legado/protótipo

```bash

php7.4 bin/finalizar_tenant.php \
    --dry-run \
    --grupo=18 \
    --instancia=core-local

php7.4 bin/finalizar_tenant.php \
    --grupo=18 \
    --instancia=core-local \
    --limit=1
```

O enqueue exige C001 em `MIGRATIONS_EXECUTADAS` ou `FINALIZACAO_ERRO`, C003
ativa em `MIGRATIONS_EXECUTADAS` e instância compatível. Uma tarefa
`PENDENTE` ou `PROCESSANDO` impede duplicidade; tarefas `CONCLUIDO` não
bloqueiam uma execução futura.

O worker assume a Q001 com bloqueio transacional, seleciona o primeiro ADMIN
ativo por `C008_Id`, descriptografa a senha do database apenas em memória e
chama o runner via `proc_open()` sem shell. `TENANT_DB_PASSWORD` segue somente
por variável de ambiente em um ambiente mínimo; stdout e stderr são limitados
e sanitizados antes de qualquer persistência ou exibição.

Em caso de sucesso, C001 e C003 mudam para `PROVISIONADO`, o acesso do grupo é
liberado e Q001 termina em `CONCLUIDO`. Em caso de falha, C001 e C003 mudam
para `FINALIZACAO_ERRO`, com retry e limite de tentativas iguais aos demais
workers. A senha do database, pura ou criptografada, nunca aparece em
argumentos, stdout, stderr, Q001, L001, CLI ou API.

### Consultar status pela API

```bash
curl https://core.example.com/api/core/groups/7/provisioning-status \
    -H 'Authorization: Bearer TOKEN'
```

`GET /api/core/groups/{id}/provisioning-status` é protegido por Bearer Token.
O usuário precisa possuir vínculo ativo em C008 com o grupo solicitado. A
resposta contém dados de C001, instância C002 atribuída, última tarefa Q001 e
os 20 logs L001 mais recentes do grupo.

Quando existir, `FINALIZAR_TENANT` aparece normalmente como a última tarefa,
junto dos estados atualizados de C001/C003 e dos logs recentes. Resposta
resumida:

```json
{
  "success": true,
  "message": "Status de provisionamento",
  "data": {
    "group": {
      "id": 7,
      "name": "Grupo Teste",
      "status": "MIGRATIONS_EXECUTADAS",
      "access_released": "N"
    },
    "instance": {
      "id": 1,
      "code": "core-local",
      "name": "Instancia local"
    },
    "database": {
      "id": 1,
      "host": "127.0.0.1",
      "port": 3306,
      "name": "hub_mkp_cli_000007",
      "user": "hmkpc000007",
      "status": "MIGRATIONS_EXECUTADAS",
      "active": "S"
    },
    "last_queue": {
      "id": 2,
      "operation": "RODAR_MIGRATIONS_CLIENTE",
      "status": "CONCLUIDO"
    },
    "logs": []
  }
}
```

Ausência ou invalidade do token retorna `401`; usuário sem vínculo ativo em
C008 retorna `403`; grupo inexistente retorna `404`. Em uma simulação,
`database` é `null`. A última tarefa pode ser `CRIAR_DATABASE_CLIENTE` ou
`RODAR_MIGRATIONS_CLIENTE`. O objeto de database expõe somente metadados do
C003 ativo; a senha criptografada nunca é selecionada nem retornada.

## Segurança e estado atual

O App resolve o contexto de database por
`POST /api/internal/tenant-database-context`, com requisição assinada. Depois de
concluir no banco mudanças de provisionamento, migrations ou finalização, o Core
chama `POST /api/internal/tenant-database-context/invalidate` no App usando o
mesmo bearer e o contrato HMAC com timestamp e nonce. A chamada aceita somente
`tenant_uuid`; falhas geram aviso técnico sem segredo e não desfazem a transação
principal. O destino é resolvido exclusivamente por
`tenants.hub_instance_id -> hub_instances.base_url`; não há fallback para outra
instância. `HUB_MKP_APP_URL` permanece apenas como fallback de navegação dos
brokers OAuth e não define o App de tenants já cadastrados. O timeout da
invalidação pode ser configurado em `HUB_APP_INTERNAL_HTTP_TIMEOUT_SECONDS`
(padrão 10 segundos).

- A conexão usa PDO com exceptions e emulação de prepared statements
  desabilitada.
- Valores dinâmicos são enviados por prepared statements.
- Tokens de confirmação devem ser persistidos somente como hash.
- Tokens de sessão são opacos, expiram e são persistidos somente como hash.
- Senhas de usuários devem ser produzidas com `password_hash()`.
- Cadastro público sempre passa por C010 antes de criar usuário ou grupo.
- Confirmações válidas somente enfileiram Q001; criação de database ocorre no
  worker e nunca em endpoint público.
- A simulação permanece padrão. O modo real requer flag local e `--real`.
- O worker real grava C003 somente após criar o recurso e testar a conexão; a
  senha é protegida por AES-256-GCM e nunca entra em logs, payloads ou API.
- Cada worker processa somente sua instância C002.
- O worker de migrations delega o schema ao runner oficial do `hub-mkp-app` e
  passa a senha somente no ambiente do processo filho.
- Provisionamento parcial exige análise manual e não dispara exclusão
  automática de database ou usuário.
- Interface visual, seeds do cliente, liberação automática de acesso e
  integrações externas não foram implementadas.

## Fase 5: coordenação operacional

O Core continua sem armazenar a fila de marketplace. Ele coordena somente:

- o cursor `worker_task_locks.last_tenant_id`, preservado no `release`;
- a ordenação circular de `GET /api/worker/operational-tenants` por
  `after_tenant_id`;
- o contexto efêmero de
  `GET /api/worker/operational-tenants/{id}/execution-context`.

O `acquire` retorna o cursor persistido. Somente o proprietário autenticado do
lock pode alterá-lo, enviando `last_tenant_id` no heartbeat. A listagem circular
retorna primeiro IDs maiores que o cursor e, depois, IDs menores ou iguais.

O execution-context exige token de Worker, restringe o tenant à
`hub_instance_id` autenticada e aceita somente tenant, instância e database
ativos e `PROVISIONADO`. A senha é descriptografada apenas para essa resposta,
que usa `Cache-Control: no-store`; ela não entra em auditoria ou logs. O Worker
a repassa ao runner do App apenas por variável de ambiente.
