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→ orquestradorPOST /trigger/onboarding. - Eco de volta — orquestrador
LinkCustomerAccount/ReportProvisioningFailed→ customer-appPOST /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 + passwordChangedAt →
createVerificationToken(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: signup → email_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 defailedLoginCount). - Gate de estado da conta:
suspended/deleted→401 account_unavailable;email_pending/signup→403 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 vereditoblocknega mesmo com senha correta (403 risk_blocked); fail-open para o caminho de MFA obrigatório em erro do engine.
Resposta:
* totpEnabled=true → 200 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 porclaimTotpStep(userId, step)(AUTH-3): um código criptograficamente válido é de uso único por janela de 30 s; um replay logamfa.totp_replay_blockede é 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 comFOR 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:
- Rate limit
onboarding-profile:{ip}(30/h,failClosed) — antifarming (ONB-1). - Gate de MFA (AUTH-01) —
!session.twoFactorVerified→403 mfa_required. - Gate de KYC —
evaluateKycGateForAccount(src/lib/kyc/gate.ts); uma contasuspendedou KYCrejected→403 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:
- Exige
stripe-signature;503seSTRIPE_WEBHOOK_SECRETnão estiver definido. stripe.webhooks.constructEvent(..., SIGNING_TOLERANCE_SECS=300); assinatura inválida →400.- Claim de idempotência
claimStripeWebhookEvent(entrega at-least-once): uma duplicata retorna200 { duplicate: true }; uma tentativa anterior não finalizada é reprocessada (isRetry). dispatch(event)→finalizeStripeWebhookEvent(outcomeapplied|unhandled|failed|ignored). Um throw do handler persistefailede retorna500, 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, retornaignored(nunca5xx, 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 paraactive(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¶
- Ignora
payment_status='unpaid'(métodos assíncronos creditam depois emasync_payment_succeeded). - Matching de conta em três passos:
stripe_customer_id→customer_email→ auto-provisão (autoCreateAccountFromStripe, paid-signup-first; cria uma conta-cascaemail_pendinge dispara um magic-link viasendOnboardingMagicLink, TTL de 15 min). complianceBlock(acima).- 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 — logCRITICAL, deixa para mapeamento manual (audit STRIPE-1); a saga ainda provisiona. applyTierFromStripe(tier + cota mensal de tokens).enqueueOnboarding(o hand-off da saga, §3.8).recordProvisioningJob— persiste de forma durável o hand-off emprovisioning_jobscom oonboardingInputexato (para que o reconciliador possa reproduzi-lo). Best-effort: uma falha de bookkeeping nunca transforma o webhook num5xx(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_URLouPROVISIONING_TRIGGER_SECRETnão estiver definido → degrada de forma segura: emitecustomer.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
POSTdowireBodyem snake_case para a trigger URL comX-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
5× 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 failed → markProvisioningJobFailed) 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"→markProvisioningJobFailed→200.status: "provisioned"→ exigedeployment_id+cell_id(senão400) →linkProvisionedAccount({stripeSessionId, deploymentId, cellId, tokens})que liga odpl_id real à conta (casada porstripe_session_id) e a viraactive. 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 janelaSTALE_AFTER_MSé longa o bastante para deixar os retries próprios do orquestrador + o callback chegarem primeiro.- Para cada job com
triggerPayloadpersistido, reproduz viaenqueueOnboarding(uma linha sem payload — pré-migração 0039 — é pulada, nunca re-mintada a partir de um palpite), depoisrecordProvisioningJobincrementa as tentativas. Um job além deMAX_ATTEMPTS=8para 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áprovisionede sai do scan.
3.12 Download da TBI + emissão de licença¶
- Download da TBI —
GET /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 (isSanctionedCountry→403 unavailablegenérico). Minta uma URL presigned de S3 de 5 minutos para a imagem de bootstrap publicada (o bucket é privado) e faz302-redirect. Rate limit 60/h por conta. - Emissão de licença —
POST /api/license/issue(src/app/api/license/issue/route.ts): autenticada + MFA-verificada (!tfv→403 mfa_required), rate limit 10/h/conta.signLicense({accountId, tier, validitySec})(default 365 d, máx 730 d); persiste uma linhalicensesguardando 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):
- Lê o license JWT de
…/bootstrap/license.jwt(chmod 0600); vazio/ausente →Fatalfcom uma dica de colar. - 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, semdocker/cni/veth), unidos por|. Hosts de dev não-Linux (sem machine-id/DMI) caem para um fingerprintsimulatedque a cloud vincula de forma idêntica. POST /api/install/manifest(Bearer license JWT) — verifica o JWT, cruza comlicensespara revogação + mismatch de conta, vincula ao fingerprint na primeira chamada (UPDATE … WHERE bound_fingerprint IS NULLatô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).- Heartbeat inicial (carimba a janela de graça) + drain inicial de uso (no-op no primeiro boot).
- 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¶
- Heartbeat —
POST /api/license/heartbeat(src/app/api/license/heartbeat/route.ts): Bearer license JWT; atualizalicenses.last_heartbeat_at+ acrescenta uma linhalicense_heartbeats; impõe o vínculo de fingerprint e a revogação; registra posture/quote TPM L3 (ADR 0104). RetornagraceExpiresAt = now + 24 h. 24 h sem heartbeat → módulos ficam read-only. - Usage report —
POST /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:
jtiexiste,accountIdcasa comclaims.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
usagePubkeyestá 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).InsufficientBalanceErrordesfaz a unidade inteira e retorna402 quotaExceeded(o controller pausa + pede top-up);AccountSuspendedError→403 account_suspended(reconhecido peloIsLicenseRejecteddo 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