Skip to content

Jornada do Cliente Pago — Low-Level Design (LLD)

Escopo. A jornada exata, ponta a ponta, de um cliente pagante da tlsstress.art: signup → verificar-email → sign-in/MFA → mint-de-onboarding → checkout Stripe → webhook → saga de onboarding → callback → active → download da TBI → instalação on-prem → metering. Cada passo abaixo cita o arquivo que o implementa. Sem endpoints, flags ou comportamentos inventados — leia o código citado junto com este documento.

ADRs autoritativas. ADR 0056 (Octopus Customer Auto-Provisioning), ADR 0099 (Token Economy v5 / ledger UTXO), ADR 0104 (Usage Attestation / antifraude).


1. Componentes

Componente Caminho Papel
customer-app pkg/octopus/customer-app Next.js (App Router, runtime=nodejs). API pública: auth, onboarding, webhook Stripe, callback de provisioning, license/usage, crons reconciliadores.
provisioning-orchestrator pkg/octopus/provisioning-orchestrator Go + worker Temporal. Roda OnboardingWorkflow (a saga de 12 activities) e expõe o POST /trigger/onboarding protegido por HMAC.
bootstrap-controller pkg/bootstrap-controller Agente Go on-prem. No primeiro boot busca o manifest de instalação e depois faz loop horário: heartbeat + usage report. Fecha o loop de metering SaaS↔on-prem.
ledger UTXO pkg/octopus/customer-app/src/lib/utxo Fonte única de verdade do saldo de TSU (ADR 0099). Mint/spend são idempotentes.

TSU — a unidade de valor

O produto faz metering em TSU (TLS-Stress Units). TSU são mintados num ledger UTXO (mintUTXO) no promo de signup, na conclusão do onboarding, no refill de assinatura e na compra de boost; e são gastos (spendTSU) quando o deployment on-prem reporta uso. O ledger é o único saldo autoritativo (src/lib/utxo/balance.ts).


2. Sequência ponta a ponta

sequenceDiagram
    autonumber
    actor U as Cliente
    participant CA as customer-app (Next.js)
    participant DB as Postgres (ledger + contas)
    participant ST as Stripe
    participant PO as provisioning-orchestrator (Temporal)
    participant BC as bootstrap-controller (on-prem)

    Note over U,CA: A. Criação de conta
    U->>CA: POST /api/v1/auth/signup
    CA->>CA: Turnstile, OFAC, pipeline de email, HIBP, Argon2id
    CA->>DB: createCustomerAndUser (status=signup→email_pending)
    CA-->>U: 201 pending_verification (neutro)
    CA->>U: link de verificação de email (SES)

    Note over U,CA: B. Verificar + sign-in + MFA
    U->>CA: GET /api/auth/verify?token=…
    CA->>DB: consumeVerificationToken + markEmailVerified (→ kyc_pending)
    CA-->>U: 302 /onboarding?step=2fa
    U->>CA: POST /api/v1/auth/mfa/enroll
    CA->>DB: grava totpSecret, totpEnabled=false, backupCodes
    U->>CA: POST /api/v1/auth/signin (email+senha)
    CA-->>U: 200 mfa_required (ACCESS_COOKIE parcial, tfv=false)
    U->>CA: POST /api/v1/auth/signin/mfa (TOTP / backup code)
    CA-->>U: 200 ok (sessão completa, tfv=true)

    Note over U,CA: C. Mint de onboarding (caminho free → active)
    U->>CA: POST /api/v1/onboarding/profile (tier1/2/3)
    CA->>CA: gate de MFA + gate de KYC
    CA->>DB: mintUTXO (TSU) + activateIfKycPending (kyc_pending→active)

    Note over U,ST: D. Checkout pago
    U->>ST: Stripe Checkout (hosted)
    ST->>CA: POST /api/stripe/webhook (checkout.session.completed)
    CA->>CA: verifica assinatura, claim de idempotência, complianceBlock()
    CA->>DB: applyTierFromStripe + recordProvisioningJob
    CA->>PO: enqueueOnboarding → POST /trigger/onboarding (HMAC)

    Note over PO,DB: E. Saga de onboarding (12 activities + compensação LIFO)
    PO->>PO: OnboardingWorkflow (KYC…MintDeploymentID…ProvisionTenant…)
    PO->>CA: Activity 9b LinkCustomerAccount → POST /api/internal/provisioning/callback (HMAC)
    CA->>DB: linkProvisionedAccount (dpl_ id + cell, status→active)
    CA-->>PO: 200 provisioned
    PO->>U: SendWelcomeEmail (URL do dashboard + JWT de onboarding)

    Note over U,BC: F. Instalação on-prem + metering
    U->>CA: GET /api/account/download/[downloadKey] (presigned S3)
    U->>CA: POST /api/license/issue (license JWT)
    BC->>CA: POST /api/install/manifest (bind do fingerprint, obtém módulos)
    loop horário
        BC->>CA: POST /api/license/heartbeat
        BC->>CA: POST /api/usage/report (spendTSU)
    end

Dois trechos HMAC cruzam a fronteira SaaS↔orquestrador, ambos com a mesma família de segredo compartilhado e ambos com X-Provisioning-Signature: hex(HMAC-SHA256(rawBody)):

  • Saída — customer-app enqueueOnboarding → orquestrador POST /trigger/onboarding.
  • Eco de volta — orquestrador LinkCustomerAccount / ReportProvisioningFailed → customer-app POST /api/internal/provisioning/callback.

3. Detalhe estágio a estágio

3.1 Signup — POST /api/v1/auth/signup

Arquivo: pkg/octopus/customer-app/src/app/api/v1/auth/signup/route.ts

O handler é um pipeline antienumeração: todo caminho de "não aceita" retorna o corpo idêntico 201 pending_verification e é preenchido até MIN_RESPONSE_MS = 600 ms (padTiming), de modo que um atacante não consiga distinguir os resultados pelo tempo.

# Gate Comportamento na falha
1 Rate limit signup:{ip} + signup-day:{ip} (failClosed: true) 429 rate_limited (com padding de tempo)
2 Zod SignupSchema (email, senha ≥12, acceptedTos: literal(true), turnstileToken) 400 invalid_payload
3 isSanctionedCountry(countryCode) (OFAC) 201 neutro + log SIEM signup.sanctioned_country
4 verifyTurnstile 201 neutro
5 validateEmail (RFC 5322, remoção de +tag, blocklist de domínio, MX/A/AAAA) 201 neutro
6 findUserByEmail (usuário existente) 201 neutro + log signup.email_already_exists
7 checkPasswordHIBP (k-anonimato, fail-open se o HIBP estiver inacessível) 400 breached_password (a única falha não-neutra — o sinal ajuda o usuário)

No sucesso: hashPasswordPeppered (Argon2id + versão atual do pepper) → createCustomerAndUser → carimba pepperVersion + passwordChangedAtcreateVerificationToken(purpose='email_verify', ttlHours=0.5) → SES verify-email. Falha no envio do email não bloqueia o signup (o usuário pode usar /api/v1/auth/resend-verification).

Antissibil (TOK-01). Bônus de indicação não são mintados no signup; o vínculo de indicação é persistido e creditado apenas quando esta conta verifica o email (creditReferralOnVerify), com gate e teto por indicador. Um promoCode do tipo tsu_grant é resgatado no signup dentro de uma única transação (incremento de usedCount, mintUTXO, insert em promoRedemptions compartilham o tx).

Status da conta após o signup: signupemail_pending (o default de createCustomerAndUser é signup; o email de verificação move o usuário rumo à verificação).

3.2 Verificar email — GET /api/auth/verify?token=…

Arquivo: pkg/octopus/customer-app/src/app/api/auth/verify/route.ts

consumeVerificationToken(token, 'email_verify') faz um SELECT-then-UPDATE de tiro único (used_at = now()) numa linha que casa com o SHA-256 do token com purpose='email_verify' AND expires_at > now() AND used_at IS NULL. Num token forjado/expirado: 302 /verify-error?code=invalid_or_expired. No sucesso: markEmailVerified avança a conta para kyc_pending, dispara creditReferralOnVerify (não-fatal) e redireciona 302 /onboarding?step=2fa.

3.3 Enrollment de MFA — POST /api/v1/auth/mfa/enroll

Arquivo: pkg/octopus/customer-app/src/app/api/v1/auth/mfa/enroll/route.ts

Exige uma sessão autenticada mas ainda não TOTP-verificada — o estado exato após verificar o email. Retorna secret (base32), qrCodeDataUrl e 10 backupCodes de uso único. No servidor: um UPDATE atômico grava totpSecret (criptografado), totpEnabled=false e os backupCodes hasheados. A flag vira true somente após /api/v1/auth/mfa/verify provar posse.

Endurecimento de reenrollment (CRIT-4). Se o TOTP já está habilitado, só uma sessão totalmente 2FA-verificada pode sobrescrever o secret; uma sessão mais fraca (ex.: magic link) recebe 403 reenrollment_requires_2fa e deve usar a recuperação de conta.

3.4 Sign-in — POST /api/v1/auth/signin

Arquivo: pkg/octopus/customer-app/src/app/api/v1/auth/signin/route.ts

Tempo uniforme (MIN_RESPONSE_MS = 700 ms); sempre roda Argon2 mesmo para um usuário desconhecido (contra um hash dummy constante), de modo que "usuário desconhecido" não possa ser distinguido pelo tempo de "usuário conhecido, senha errada".

  • Rate limit signin-ip:{ip} (30/5 min) + lockout progressivo por usuário: após 5 falhas, lockoutUntil = now + min(30 min, 2^count s) (incremento atômico de failedLoginCount).
  • Gate de estado da conta: suspended/deleted401 account_unavailable; email_pending/signup403 email_verification_required.
  • Rehash transparente se os parâmetros de pepper/Argon2 estiverem desatualizados.
  • Risk engine (H-6)assessRisk (viagem impossível, score de bot/ameaça da CF, reputação de IP). Um veredito block nega mesmo com senha correta (403 risk_blocked); fail-open para o caminho de MFA obrigatório em erro do engine.

Resposta: * totpEnabled=true200 mfa_required com um ACCESS_COOKIE parcial (tfv=false); audit mfa_challenge. * totpEnabled=false (janela estreita do primeiro login) → 200 mfa_enrollment_required com cookies parciais de access + refresh para que o usuário alcance /api/v1/auth/mfa/enroll.

3.5 Verificação de MFA (passo 2) — POST /api/v1/auth/signin/mfa

Arquivo: pkg/octopus/customer-app/src/app/api/v1/auth/signin/mfa/route.ts

Lê o ACCESS_COOKIE parcial (precisa ter tfv=false), aplica rate limit signin-mfa:{sub} (10/5 min) e aceita ou:

  • um TOTP de 6 dígitos — verificado por verifyTOTPStored; protegido contra replay por claimTotpStep(userId, step) (AUTH-3): um código criptograficamente válido é de uso único por janela de 30 s; um replay loga mfa.totp_replay_blocked e é rejeitado.
  • um backup code de 12 hex — o regex ^\d{6}$|^[0-9A-Fa-f][0-9A-Fa-f \-]{11,17}$ normaliza traços/espaços/caixa; o ciclo select-verify-burn roda numa transação com FOR UPDATE, de modo que duas requisições concorrentes com o mesmo código não possam ambas passar (audit MFA-BACKUP-1 — a antiga forma [A-Z0-9]{8} nunca casava com um código real, então os backup codes eram estruturalmente inutilizáveis).

No sucesso: emite um token de acesso completo (tfv=true) + refresh token, persiste a linha de refresh, audita login_success, retorna 200 { ok, next: '/account' }. Secrets TOTP legados em texto puro são re-criptografados via KMS em fire-and-forget.

3.6 Mint de onboarding — POST /api/v1/onboarding/profile

Arquivo: pkg/octopus/customer-app/src/app/api/v1/onboarding/profile/route.ts

Este é o endpoint de ativação de free-tier + mint de TSU. Três gates antes do mint:

  1. Rate limit onboarding-profile:{ip} (30/h, failClosed) — antifarming (ONB-1).
  2. Gate de MFA (AUTH-01)!session.twoFactorVerified403 mfa_required.
  3. Gate de KYCevaluateKycGateForAccount (src/lib/kyc/gate.ts); uma conta suspended ou KYC rejected403 compliance_hold.
Tier (action) Mint (TSU) sourceRef (idempotência) Pré-requisito
tier1 (segmento + caso de uso) 500.000 onboarding:tier1:{accountId}
tier2 (frequência + destino + volume + cicd/white-label) 300.000 onboarding:tier2:{accountId} tier1
tier3 (pain points, 1–3) 200.000 onboarding:tier3:{accountId} tier1 + tier2
bônus de conclusão (os 3) 100.000 onboarding:bonus:{accountId} tiers 1+2+3
dismiss 0

Cada tier é idempotente (reenviar um tier concluído retorna a linha existente, não minta nada). totalTokensEarned é incrementado com uma expressão SQL para evitar uma race TOCTOU. Após o tier1, activateIfKycPending promove uma conta kyc_pending para active (contas pagas já estão active via webhook Stripe; a cláusula WHERE status='kyc_pending' é o filtro de segurança).

3.7 Checkout Stripe + webhook — POST /api/stripe/webhook

Arquivo: pkg/octopus/customer-app/src/app/api/stripe/webhook/route.ts

O webhook é a espinha do caminho pago. Tratamento do envelope:

  1. Exige stripe-signature; 503 se STRIPE_WEBHOOK_SECRET não estiver definido.
  2. stripe.webhooks.constructEvent(..., SIGNING_TOLERANCE_SECS=300); assinatura inválida → 400.
  3. Claim de idempotência claimStripeWebhookEvent (entrega at-least-once): uma duplicata retorna 200 { duplicate: true }; uma tentativa anterior não finalizada é reprocessada (isRetry).
  4. dispatch(event)finalizeStripeWebhookEvent (outcome applied|unhandled|failed|ignored). Um throw do handler persiste failed e retorna 500, então a Stripe reentrega com backoff — todo handler é idempotente.

Tipos de evento tratados → handlers:

Evento Handler
checkout.session.completed / checkout.session.async_payment_succeeded handleCheckoutCompleted (ou handleBoostCheckoutCompleted se metadata.sku começar com boost-)
customer.subscription.created / .updated handleSubscriptionChange
customer.subscription.deleted handleSubscriptionDeleted
invoice.paid handleInvoicePaid
invoice.payment_failed handleInvoicePaymentFailed
charge.refunded handleChargeRefunded (clawbackChargeTokens)
charge.dispute.created handleDisputeCreated (suspende primeiro, depois clawback)

complianceBlock — o gate OFAC/KYC em todo caminho pago

complianceBlock(account, countryFromSession) retorna um motivo de bloqueio quando o país (derivado da sessão, com fallback para o país armazenado na conta) é sancionado, ou quando evaluateKycGate({status, kycStatus}) nega (suspended / KYC-rejected). Está ligado a todo handler que carrega dinheiro:

  • handleCheckoutCompleted — em bloqueio: suspendAccount(compliance_hold:{reason}), finaliza sem enfileirar o onboarding, retorna ignored (nunca 5xx, então a Stripe não fica num loop de retry sobre um hold permanente).
  • handleSubscriptionChange — re-screena; sem isso um evento de assinatura reverteria uma conta recém-bloqueada de volta para active (audit V2).
  • handleInvoicePaid — pula o mint recorrente para um cliente agora sancionado.
  • handleBoostCheckoutCompleted — o caminho de boost (TSU de compra única); o último caminho pago que não tinha o gate (audit STRIPE-DEEP-01).

handleCheckoutCompleted — o caminho feliz

  1. Ignora payment_status='unpaid' (métodos assíncronos creditam depois em async_payment_succeeded).
  2. Matching de conta em três passos: stripe_customer_idcustomer_emailauto-provisão (autoCreateAccountFromStripe, paid-signup-first; cria uma conta-casca email_pending e dispara um magic-link via sendOnboardingMagicLink, TTL de 15 min).
  3. complianceBlock (acima).
  4. Resolve o tier a partir do price do primeiro line item (priceIdToTier). Um price não mapeado (resolve para free/0) não é aplicado a um checkout pago — log CRITICAL, deixa para mapeamento manual (audit STRIPE-1); a saga ainda provisiona.
  5. applyTierFromStripe (tier + cota mensal de tokens).
  6. enqueueOnboarding (o hand-off da saga, §3.8).
  7. recordProvisioningJob — persiste de forma durável o hand-off em provisioning_jobs com o onboardingInput exato (para que o reconciliador possa reproduzi-lo). Best-effort: uma falha de bookkeeping nunca transforma o webhook num 5xx (launch blocker A).

3.8 Hand-off de enqueue — enqueueOnboarding

Arquivo: pkg/octopus/customer-app/src/lib/provisioning/enqueue.ts

Faz a ponte de um checkout liquidado para o orquestrador Go. Nunca lança — uma falha de hand-off não pode transformar o webhook Stripe num 5xx.

  • Se PROVISIONING_TRIGGER_URL ou PROVISIONING_TRIGGER_SECRET não estiver definido → degrada de forma segura: emite customer.onboarding.enqueue {transport:"log", reason:"trigger_not_configured"} e retorna { enqueued: false }. O reconciliador (ou o orquestrador cross-cloud) o captura.
  • Caso contrário: faz POST do wireBody em snake_case para a trigger URL com X-Provisioning-Signature = hex(HMAC-SHA256(body)), timeout de 10 s. Não-2xx → { enqueued: false, reason: 'http_<status>' }; erro de rede → { enqueued: false, reason: 'fetch_error' }.

O POST /trigger/onboarding do orquestrador (provisioning-orchestrator/cmd/provisioner/main.go::onboardingTriggerHandler) verifica o HMAC (constant-time, corpo capado em 64 KB), faz unmarshal de OnboardingInput, exige stripe_session_id + email, e chama StartOnboarding. O WorkflowID é "onboarding-" + StripeSessionID (worker.go), de modo que um webhook reentregue deduplica para a mesma execução Temporal.

3.9 Saga de onboarding — OnboardingWorkflow

Arquivo: pkg/octopus/provisioning-orchestrator/internal/workflows/onboarding_temporal.go (activities: internal/activities/activities.go, internal/activities/real_provider_callback.go)

O workflow Temporal real. Opções de activity: StartToCloseTimeout=30 s, retry com backoff (×2, teto 20 s). A saga inteira é limitada por WorkflowTimeout = 15 min (onboarding_run.go) — grande o bastante para cobrir o orçamento de retry de toda activity mais a cadeia de compensação, de modo que um timeout server-side nunca dispare no meio da saga e pule o rollback diferido.

12 activities (passos 1–12; o passo 9b é o eco de volta):

# Activity Compensação empilhada (LIFO)
1 KYCCheck (resultado limpo; um non-pass é uma decisão de workflow, ErrKYCFailed, não retentado)
2 MintDeploymentID (ULID dpl_) MarkDeploymentIDRolledBack
3 AllocateCell (GeoIP countryCode → cell mais próxima) ReleaseCellAllocation
4 IssueClientCert (Vault PKI CA por cell) RevokeClientCert
5 ProvisionTenant (Postgres RLS + namespace Redis) MarkTenantRollbackPending
6 AllocateTokenQuota (tokensForSlug(packageSlug)) BurnTokenQuota
7 ReserveConnectArtSlot ReleaseConnectArtSlot
8 ReserveStunCoordSlot ReleaseStunCoordSlot
9 GenerateOnboardingJWT (→ https://dashboard.tlsstress.art/onboarding?token=…)
9b LinkCustomerAccount (eco de volta) — POSTa o dpl_ id + cell + cota para o customer-app; último passo falível — (limpa a cadeia no sucesso)
10 SendWelcomeEmail (pós no-return)
11 AppendAuditChain (best-effort)
12 NotifyAdminHighValue (AmountCents ≥ HighValueAmountCentsThreshold = $500, best-effort)

tokensForSlug (onboarding_run.go): free-trial→1.000, pro-monthly→100.000, enterprise-monthly→1.000.000, defense-monthly→10.000.000, default→0.

O eco de volta LinkCustomerAccount (Activity 9b)

Arquivo: internal/activities/real_provider_callback.go

POSTa {stripe_session_id, status:"provisioned", deployment_id, cell_id, tokens_quota} para o POST /api/internal/provisioning/callback do customer-app, assinado por HMAC. Fail-closed quando a URL/secret do callback não está cabeada (ErrNotProvisioned). Um 404 (o customer-app ainda não gravou a linha de provisioning_jobs — race do webhook) é ErrTransient, então o Temporal retenta; a linha aparece em segundos. Só um 200 confirma o link.

Ele roda como o último passo falível, logo antes do email de boas-vindas no-return, precisamente para que, se falhar, a saga diferida desenrole com a conta nunca virada para active (a linha de provisioning_jobs fica unprovisioned e o reconciliador re-drive) — em vez de um meio-estado permanente.

O ponto no-return + ReportProvisioningFailed

linked := true vira após o passo 9b. Então rollback = nil limpa a cadeia de compensação antes do email de boas-vindas (audit V1): caso contrário, um erro transiente de SendWelcomeEmail (ErrTransient num soluço do Postmark) desenrolaria a saga inteira — revogando o cert, zerando a cota, liberando slots — num cliente que já está active no customer-app, sem nada para reparar (a linha de provisioning_jobs já está provisioned, excluída do reconciliador).

Em qualquer falha terminal a função diferida roda a pilha de compensação LIFO num contexto desconectado (para que o cancelamento do workflow não aborte o rollback) e então — somente se !linked — dispara ReportProvisioningFailed (callback de status failedmarkProvisioningJobFailed) e SendProvisioningFailedEmail com um motivo genérico (nunca vazando detalhe de KYC/sanções). Ambos eram código morto, com zero call sites.

flowchart TD
    Start([Início do workflow]) --> A1[1. KYCCheck]
    A1 -->|!Passed| KFAIL[/return ErrKYCFailed/]
    A1 -->|Passed| A2[2. MintDeploymentID]
    A2 --> A3[3. AllocateCell]
    A3 --> A4[4. IssueClientCert]
    A4 --> A5[5. ProvisionTenant]
    A5 --> A6[6. AllocateTokenQuota]
    A6 --> A7[7. ReserveConnectArtSlot]
    A7 --> A8[8. ReserveStunCoordSlot]
    A8 --> A9[9. GenerateOnboardingJWT]
    A9 --> A9b{9b. LinkCustomerAccount}
    A9b -->|2xx| LINKED["linked = true<br/>rollback = nil<br/>PONTO NO-RETURN"]
    A9b -->|erro| FAIL
    LINKED --> A10[10. SendWelcomeEmail]
    A10 -->|erro| PROVISIONED_NOEMAIL["return error<br/>(provisionado; reenvio manual)<br/>SEM unwind"]
    A10 -->|ok| A11[11. AppendAuditChain best-effort]
    A11 --> A12[12. NotifyAdminHighValue se ≥ $500]
    A12 --> Done([OnboardingResult])

    KFAIL --> FAIL
    FAIL{{"diferido: err != nil"}}
    FAIL --> COMP["Contexto desconectado<br/>roda pilha de rollback LIFO:<br/>RevokeClientCert →<br/>ReleaseStun/ConnectArt →<br/>BurnTokenQuota →<br/>MarkTenantRollbackPending →<br/>ReleaseCellAllocation →<br/>MarkDeploymentIDRolledBack"]
    COMP --> NL{linked?}
    NL -->|false| REPORT["ReportProvisioningFailed<br/>(callback status:failed)<br/>+ SendProvisioningFailedEmail (genérico)"]
    NL -->|true| NOOP["sem aviso de falha<br/>(o cliente ESTÁ provisionado)"]
    REPORT --> End([terminal])
    NOOP --> End
    PROVISIONED_NOEMAIL --> End

3.10 Callback de provisioning — POST /api/internal/provisioning/callback

Arquivo: pkg/octopus/customer-app/src/app/api/internal/provisioning/callback/route.ts

O receptor do eco de volta no customer-app. Auth: HMAC-SHA256 sobre o corpo cru (PROVISIONING_CALLBACK_SECRET preferido, com fallback para PROVISIONING_TRIGGER_SECRET), comparado em constant-time. Falha fechado: sem secret → 503; assinatura inválida → 401.

  • status: "failed"markProvisioningJobFailed200.
  • status: "provisioned" → exige deployment_id + cell_id (senão 400) → linkProvisionedAccount({stripeSessionId, deploymentId, cellId, tokens}) que liga o dpl_ id real à conta (casada por stripe_session_id) e a vira active. Se nenhum job casar com a sessão → 404, para que o orquestrador retente/escale em vez de assumir sucesso.

Este é o passo que aposenta o placeholder pending- do deployment_id: antes do callback existir, o deployment_id da conta ficava pending- para sempre e o activateAccount() era código morto (launch blocker — Cluster A).

3.11 Watchdog reconciliador — POST /api/cron/reconcile-provisioning

Arquivo: pkg/octopus/customer-app/src/app/api/cron/reconcile-provisioning/route.ts

Fecha a lacuna de durabilidade: um hand-off pode emperrar (trigger inalcançável / cell down / não-2xx / saga falhou no meio). Auth: x-cron-secret (constant-time, SHA-256). Sempre retorna 200 (ele reporta).

  • getStaleProvisioningJobs({staleBefore: now − 5 min, maxAttempts: 8, limit: 25}) — a janela STALE_AFTER_MS é longa o bastante para deixar os retries próprios do orquestrador + o callback chegarem primeiro.
  • Para cada job com triggerPayload persistido, reproduz via enqueueOnboarding (uma linha sem payload — pré-migração 0039 — é pulada, nunca re-mintada a partir de um palpite), depois recordProvisioningJob incrementa as tentativas. Um job além de MAX_ATTEMPTS=8 para de ser retentado mas continua visível (o gauge de "stuck" do /api/metrics + AlertManager paginam sobre ele). Um job ligado pelo callback nesse meio-tempo está provisioned e sai do scan.

3.12 Download da TBI + emissão de licença

  • Download da TBIGET /api/account/download/[downloadKey] (src/app/api/account/download/[downloadKey]/route.ts): sessão autenticada, conta ativa (não-suspended/deleted) e checagem de país OFAC/embargo (isSanctionedCountry403 unavailable genérico). Minta uma URL presigned de S3 de 5 minutos para a imagem de bootstrap publicada (o bucket é privado) e faz 302-redirect. Rate limit 60/h por conta.
  • Emissão de licençaPOST /api/license/issue (src/app/api/license/issue/route.ts): autenticada + MFA-verificada (!tfv403 mfa_required), rate limit 10/h/conta. signLicense({accountId, tier, validitySec}) (default 365 d, máx 730 d); persiste uma linha licenses guardando só jti + kid (nunca o token). O JWT é exibido uma vez para o operador colar na caixa on-prem.

3.13 Instalação on-prem — primeiro boot do bootstrap-controller

Arquivo: pkg/bootstrap-controller/cmd/bootstrap-controller/main.go

Primeiro boot (state dir default /var/lib/tlsstress/bootstrap):

  1. Lê o license JWT de …/bootstrap/license.jwt (chmod 0600); vazio/ausente → Fatalf com uma dica de colar.
  2. Computa o fingerprint de hardware (internal/fingerprint): SHA-256 sobre o primeiro disponível entre /etc/machine-id, depois /sys/class/dmi/id/product_uuid, depois hostname + o primeiro MAC estável (não-virtual, sem docker/cni/veth), unidos por |. Hosts de dev não-Linux (sem machine-id/DMI) caem para um fingerprint simulated que a cloud vincula de forma idêntica.
  3. POST /api/install/manifest (Bearer license JWT) — verifica o JWT, cruza com licenses para revogação + mismatch de conta, vincula ao fingerprint na primeira chamada (UPDATE … WHERE bound_fingerprint IS NULL atômico, de modo que uma race de JWT roubado não possa fazer double-bind), opcionalmente faz enroll da pubkey de uso L1, e retorna os image refs de módulo por tier + URLs de nuvem + heartbeatIntervalSec=3600 + gracePeriodSec=86400. Os manifests são escritos sob /var/lib/tlsstress/manifests/ (um systemd unit externo os aplica).
  4. Heartbeat inicial (carimba a janela de graça) + drain inicial de uso (no-op no primeiro boot).
  5. Entra no loop agendado: heartbeat horário + usage report horário.

Um signer de atestação L1 (Ed25519 host-held, ADR 0104) é carregado/criado; um lease broker L2 opcional pode gate as runs contra a nuvem assim que o heartbeat ficar stale além da janela de graça.

3.14 Loop de metering — heartbeat + usage report

  • HeartbeatPOST /api/license/heartbeat (src/app/api/license/heartbeat/route.ts): Bearer license JWT; atualiza licenses.last_heartbeat_at + acrescenta uma linha license_heartbeats; impõe o vínculo de fingerprint e a revogação; registra posture/quote TPM L3 (ADR 0104). Retorna graceExpiresAt = now + 24 h. 24 h sem heartbeat → módulos ficam read-only.
  • Usage reportPOST /api/usage/report (src/app/api/usage/report/route.ts): Bearer license JWT; consumo de TSU append-only vindo do reporter de spend on-prem.
  • Checagens de licença: jti existe, accountId casa com claims.sub (defesa em profundidade), não revogada, fingerprint-bound (uma licença não vinculada nunca reporta — license_not_bound), fingerprint casa.
  • Atestação L1 (ADR 0104): quando uma usagePubkey está enrolada e o modo ≠ off, todo report deve carregar um envelope assinado (licença, fingerprint, faixa de seq, nonce, digest de eventos). envelope_missing é tolerante em modo advisory; uma regressão de seq, assinatura inválida ou nonce reusado num envelope assinado é rejeitada hard mesmo em modo advisory.
  • Insert + debit atômico (M1/A6): as linhas de uso e o debit de TSU (spendTSU(... , tx)) commitam em uma transação, de-dupadas por (license_id, module, client_seq). InsufficientBalanceError desfaz a unidade inteira e retorna 402 quotaExceeded (o controller pausa + pede top-up); AccountSuspendedError403 account_suspended (reconhecido pelo IsLicenseRejected do controller, que para o loop). O saldo restante é lido do ledger UTXO em todo caminho (fonte única de verdade, C1).

4. Máquina de estados do status da conta

stateDiagram-v2
    [*] --> signup: createCustomerAndUser
    signup --> email_pending: email de verificação enviado
    email_pending --> kyc_pending: GET /api/auth/verify (markEmailVerified)
    kyc_pending --> active: onboarding tier1 (activateIfKycPending) — caminho FREE
    kyc_pending --> active: callback de provisioning linkProvisionedAccount — caminho PAGO
    email_pending --> active: webhook Stripe applyTierFromStripe (paid-signup-first)
    active --> suspended: complianceBlock / refund / dispute / past_due
    suspended --> active: revisão de ops (manual)
    active --> churned: assinatura deletada
    active --> deletion_pending: DSAR (GDPR Art.17)
    deletion_pending --> deleted: cron process-deletions
    suspended --> [*]
    deleted --> [*]
Transição Gatilho Código
signup → email_pending email de verificação emitido signup/route.ts
email_pending → kyc_pending email verificado auth/verify/route.ts::markEmailVerified
kyc_pending → active (free) onboarding tier1 onboarding/profile/route.ts::activateIfKycPending
kyc_pending/email_pending → active (pago) apply de tier no webhook / callback da saga stripe/webhook::applyTierFromStripe, provisioning/callback::linkProvisionedAccount
* → suspended compliance / refund / dispute / dunning stripe/webhook::complianceBlock, handleChargeRefunded, handleDisputeCreated

5. Matriz de idempotência, compensação e modos de falha

Estágio Chave de idempotência Modo de falha → comportamento
Signup unicidade de email (DB) race de conflito → 201 neutro
Verify UPDATE used_at de tiro único token reusado/expirado → 302 /verify-error
Verify MFA (backup) burn com FOR UPDATE em tx mesmo código concorrente → só um faz burn
Mint de onboarding sourceRef por tier reenvio de tier concluído → não minta nada
Webhook Stripe claimStripeWebhookEvent (id do evento Stripe) throw do handler → 500, Stripe reentrega; duplicata → 200 duplicate
Mint de refill de assinatura stripeInvoiceId (por invoice) retry → sem crédito duplo
Enqueue WorkflowID onboarding-{sessionId} trigger down → enqueued:false, reconciliador re-drive
Activities da saga IdempotencyKey(wfID, name, attempt) erro de activity → retry Temporal 5×; terminal → compensação LIFO
Callback casado por stripe_session_id sem job → 404, orquestrador retenta
Usage report (license_id, module, client_seq) batch duplicado → 0 linhas, 0 debit

6. Configuração (ambiente)

Variável Usada por Efeito quando não definida
STRIPE_WEBHOOK_SECRET webhook 503 webhook_not_configured
PROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET enqueueOnboarding + trigger do orquestrador degrada seguro → enqueue só por log; reconciliador re-drive
PROVISIONING_CALLBACK_SECRET (fallback para …TRIGGER_SECRET) callback de provisioning 503 (falha fechado)
CRON_SECRET cron de reconcile 503 cron_not_configured
KMS_TOTP_KEY_ARN verify de MFA pula re-criptografia de secret legado
USAGE_ATTESTATION_MODE usage report semântica advisory (vs enforce)
ECR_REGISTRY install manifest sem registryAuth (não-fatal)
APP_BASE_URL / PUBLIC_APP_URL links de verify/magic default https://app.tlsstress.art

7. Referências cruzadas

  • ADR 0056 — docs/ADR/0056-octopus-customer-auto-provisioning.md
  • ADR 0099 — Token Economy v5 (ledger UTXO como fonte única de verdade)
  • ADR 0104 — docs/ADR/0104-usage-attestation-anti-fraud.md
  • Shape da saga (puro, unit-testável) — provisioning-orchestrator/internal/workflows/onboarding_run.go
  • Gate de KYC/compliance — customer-app/src/lib/kyc/gate.ts