Skip to content

Viaje del Cliente de Pago — Low-Level Design (LLD)

Alcance. El viaje exacto, de extremo a extremo, de un cliente de pago de tlsstress.art: signup → verificar-email → sign-in/MFA → mint-de-onboarding → checkout de Stripe → webhook → saga de onboarding → callback → active → descarga de la TBI → instalación on-prem → metering. Cada paso a continuación cita el archivo que lo implementa. Sin endpoints, flags ni comportamientos inventados — lea el código citado junto a 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 Ruta Rol
customer-app pkg/octopus/customer-app Next.js (App Router, runtime=nodejs). API pública: auth, onboarding, webhook de Stripe, callback de provisioning, license/usage, crons reconciliadores.
provisioning-orchestrator pkg/octopus/provisioning-orchestrator Go + worker de Temporal. Ejecuta OnboardingWorkflow (la saga de 12 activities) y expone el POST /trigger/onboarding protegido por HMAC.
bootstrap-controller pkg/bootstrap-controller Agente Go on-prem. En el primer arranque obtiene el manifest de instalación y luego hace bucle horario: heartbeat + usage report. Cierra el bucle de metering SaaS↔on-prem.
ledger UTXO pkg/octopus/customer-app/src/lib/utxo Fuente única de verdad del saldo de TSU (ADR 0099). Mint/spend son idempotentes.

TSU — la unidad de valor

El producto hace metering en TSU (TLS-Stress Units). Los TSU se mintan en un ledger UTXO (mintUTXO) en el promo de signup, al completar el onboarding, en el refill de suscripción y en la compra de boost; y se gastan (spendTSU) cuando el deployment on-prem reporta uso. El ledger es el único saldo autoritativo (src/lib/utxo/balance.ts).


2. Secuencia de extremo a extremo

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

    Note over U,CA: A. Creación de cuenta
    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: enlace de verificación 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: guarda totpSecret, totpEnabled=false, backupCodes
    U->>CA: POST /api/v1/auth/signin (email+contraseña)
    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 (sesión completa, tfv=true)

    Note over U,CA: C. Mint de onboarding (camino 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 de pago
    U->>ST: Stripe Checkout (hosted)
    ST->>CA: POST /api/stripe/webhook (checkout.session.completed)
    CA->>CA: verifica firma, claim de idempotencia, complianceBlock()
    CA->>DB: applyTierFromStripe + recordProvisioningJob
    CA->>PO: enqueueOnboarding → POST /trigger/onboarding (HMAC)

    Note over PO,DB: E. Saga de onboarding (12 activities + compensación 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 del dashboard + JWT de onboarding)

    Note over U,BC: F. Instalación 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 del fingerprint, obtiene módulos)
    loop horario
        BC->>CA: POST /api/license/heartbeat
        BC->>CA: POST /api/usage/report (spendTSU)
    end

Dos tramos HMAC cruzan la frontera SaaS↔orquestador, ambos con la misma familia de secreto compartido y ambos con X-Provisioning-Signature: hex(HMAC-SHA256(rawBody)):

  • Salida — customer-app enqueueOnboarding → orquestador POST /trigger/onboarding.
  • Eco de vuelta — orquestador LinkCustomerAccount / ReportProvisioningFailed → customer-app POST /api/internal/provisioning/callback.

3. Detalle etapa por etapa

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

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

El handler es un pipeline antienumeración: todo camino de "no acepta" devuelve el cuerpo idéntico 201 pending_verification y se rellena hasta MIN_RESPONSE_MS = 600 ms (padTiming), de modo que un atacante no pueda distinguir los resultados por el tiempo.

# Gate Comportamiento al fallar
1 Rate limit signup:{ip} + signup-day:{ip} (failClosed: true) 429 rate_limited (con padding de tiempo)
2 Zod SignupSchema (email, contraseña ≥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, quita +tag, blocklist de dominio, MX/A/AAAA) 201 neutro
6 findUserByEmail (usuario existente) 201 neutro + log signup.email_already_exists
7 checkPasswordHIBP (k-anonimato, fail-open si HIBP es inalcanzable) 400 breached_password (el único fallo no-neutro — la señal ayuda al usuario)

En éxito: hashPasswordPeppered (Argon2id + versión actual del pepper) → createCustomerAndUser → estampa pepperVersion + passwordChangedAtcreateVerificationToken(purpose='email_verify', ttlHours=0.5) → SES verify-email. El fallo de envío del email no bloquea el signup (el usuario puede usar /api/v1/auth/resend-verification).

Anti-Sybil (TOK-01). Los bonos de referido no se mintan en el signup; el vínculo de referido se persiste y se acredita solo cuando esta cuenta verifica su email (creditReferralOnVerify), con gate y tope por referidor. Un promoCode de tipo tsu_grant se canjea en el signup dentro de una sola transacción (incremento de usedCount, mintUTXO, insert en promoRedemptions comparten el tx).

Estado de la cuenta tras el signup: signupemail_pending (el default de createCustomerAndUser es signup; el email de verificación mueve al usuario hacia la verificación).

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

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

consumeVerificationToken(token, 'email_verify') hace un SELECT-then-UPDATE de un solo disparo (used_at = now()) sobre una fila que coincide con el SHA-256 del token con purpose='email_verify' AND expires_at > now() AND used_at IS NULL. Con un token falsificado/expirado: 302 /verify-error?code=invalid_or_expired. En éxito: markEmailVerified avanza la cuenta a kyc_pending, dispara creditReferralOnVerify (no-fatal) y redirige 302 /onboarding?step=2fa.

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

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

Requiere una sesión autenticada pero aún no verificada por TOTP — el estado exacto tras verificar el email. Devuelve secret (base32), qrCodeDataUrl y 10 backupCodes de un solo uso. En el servidor: un UPDATE atómico graba totpSecret (cifrado), totpEnabled=false y los backupCodes hasheados. El flag pasa a true solo tras que /api/v1/auth/mfa/verify pruebe posesión.

Endurecimiento de reenrollment (CRIT-4). Si el TOTP ya está habilitado, solo una sesión plenamente verificada por 2FA puede sobrescribir el secret; una sesión más débil (p. ej. magic link) recibe 403 reenrollment_requires_2fa y debe usar la recuperación de cuenta.

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

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

Tiempo uniforme (MIN_RESPONSE_MS = 700 ms); siempre ejecuta Argon2 incluso para un usuario desconocido (contra un hash dummy constante), de modo que "usuario desconocido" no se pueda distinguir por el tiempo de "usuario conocido, contraseña incorrecta".

  • Rate limit signin-ip:{ip} (30/5 min) + bloqueo progresivo por usuario: tras 5 fallos, lockoutUntil = now + min(30 min, 2^count s) (incremento atómico de failedLoginCount).
  • Gate de estado de la cuenta: suspended/deleted401 account_unavailable; email_pending/signup403 email_verification_required.
  • Rehash transparente si los parámetros de pepper/Argon2 están desactualizados.
  • Risk engine (H-6)assessRisk (viaje imposible, score de bot/amenaza de CF, reputación de IP). Un veredicto block deniega incluso con la contraseña correcta (403 risk_blocked); fail-open al camino de MFA obligatorio ante un error del engine.

Respuesta: * totpEnabled=true200 mfa_required con un ACCESS_COOKIE parcial (tfv=false); audit mfa_challenge. * totpEnabled=false (ventana estrecha del primer login) → 200 mfa_enrollment_required con cookies parciales de access + refresh para que el usuario alcance /api/v1/auth/mfa/enroll.

3.5 Verificación de MFA (paso 2) — POST /api/v1/auth/signin/mfa

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

Lee el ACCESS_COOKIE parcial (debe tener tfv=false), aplica rate limit signin-mfa:{sub} (10/5 min) y acepta o bien:

  • un TOTP de 6 dígitos — verificado por verifyTOTPStored; protegido contra replay por claimTotpStep(userId, step) (AUTH-3): un código criptográficamente válido es de un solo uso por ventana de 30 s; un replay loguea mfa.totp_replay_blocked y se rechaza.
  • un backup code de 12 hex — el regex ^\d{6}$|^[0-9A-Fa-f][0-9A-Fa-f \-]{11,17}$ normaliza guiones/espacios/mayúsculas; el ciclo select-verify-burn corre en una transacción con FOR UPDATE, de modo que dos solicitudes concurrentes con el mismo código no puedan pasar ambas (audit MFA-BACKUP-1 — la antigua forma [A-Z0-9]{8} nunca coincidía con un código real, por lo que los backup codes eran estructuralmente incanjeables).

En éxito: emite un token de acceso completo (tfv=true) + refresh token, persiste la fila de refresh, audita login_success, devuelve 200 { ok, next: '/account' }. Los secrets TOTP legacy en texto plano se re-cifran vía KMS en fire-and-forget.

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

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

Este es el endpoint de activación de free-tier + mint de TSU. Tres gates antes del 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); una cuenta suspended o KYC rejected403 compliance_hold.
Tier (action) Mint (TSU) sourceRef (idempotencia) Prerrequisito
tier1 (segmento + caso de uso) 500.000 onboarding:tier1:{accountId}
tier2 (frecuencia + destino + volumen + cicd/white-label) 300.000 onboarding:tier2:{accountId} tier1
tier3 (pain points, 1–3) 200.000 onboarding:tier3:{accountId} tier1 + tier2
bono de finalización (los 3) 100.000 onboarding:bonus:{accountId} tiers 1+2+3
dismiss 0

Cada tier es idempotente (reenviar un tier completado devuelve la fila existente, no minta nada). totalTokensEarned se incrementa con una expresión SQL para evitar una race TOCTOU. Tras el tier1, activateIfKycPending promueve una cuenta kyc_pending a active (las cuentas de pago ya están active vía el webhook de Stripe; la cláusula WHERE status='kyc_pending' es el filtro de seguridad).

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

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

El webhook es la columna vertebral del camino de pago. Manejo del sobre:

  1. Exige stripe-signature; 503 si STRIPE_WEBHOOK_SECRET no está definido.
  2. stripe.webhooks.constructEvent(..., SIGNING_TOLERANCE_SECS=300); firma inválida → 400.
  3. Claim de idempotencia claimStripeWebhookEvent (entrega at-least-once): una duplicada devuelve 200 { duplicate: true }; un intento previo no finalizado se reprocesa (isRetry).
  4. dispatch(event)finalizeStripeWebhookEvent (outcome applied|unhandled|failed|ignored). Un throw del handler persiste failed y devuelve 500, así Stripe reentrega con backoff — todo handler es idempotente.

Tipos de evento manejados → handlers:

Evento Handler
checkout.session.completed / checkout.session.async_payment_succeeded handleCheckoutCompleted (o handleBoostCheckoutCompleted si metadata.sku empieza con 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 primero, luego clawback)

complianceBlock — el gate OFAC/KYC en todo camino de pago

complianceBlock(account, countryFromSession) devuelve un motivo de bloqueo cuando el país (derivado de la sesión, con fallback al país almacenado en la cuenta) está sancionado, o cuando evaluateKycGate({status, kycStatus}) deniega (suspended / KYC-rejected). Está cableado en todos los handlers que mueven dinero:

  • handleCheckoutCompleted — en bloqueo: suspendAccount(compliance_hold:{reason}), finaliza sin encolar el onboarding, devuelve ignored (nunca 5xx, así Stripe no entra en bucle de retry sobre un hold permanente).
  • handleSubscriptionChange — re-screenea; sin esto un evento de suscripción revertiría una cuenta recién bloqueada de vuelta a active (audit V2).
  • handleInvoicePaid — salta el mint recurrente para un cliente ahora sancionado.
  • handleBoostCheckoutCompleted — el camino de boost (TSU de compra única); el último camino de pago que carecía del gate (audit STRIPE-DEEP-01).

handleCheckoutCompleted — el camino feliz

  1. Ignora payment_status='unpaid' (los métodos asíncronos acreditan después en async_payment_succeeded).
  2. Matching de cuenta en tres pasos: stripe_customer_idcustomer_emailauto-provisión (autoCreateAccountFromStripe, paid-signup-first; crea una cuenta-cáscara email_pending y dispara un magic-link vía sendOnboardingMagicLink, TTL de 15 min).
  3. complianceBlock (arriba).
  4. Resuelve el tier desde el price del primer line item (priceIdToTier). Un price no mapeado (resuelve a free/0) no se aplica a un checkout de pago — log CRITICAL, se deja para mapeo manual (audit STRIPE-1); la saga igual provisiona.
  5. applyTierFromStripe (tier + cuota mensual de tokens).
  6. enqueueOnboarding (el hand-off de la saga, §3.8).
  7. recordProvisioningJob — persiste de forma durable el hand-off en provisioning_jobs con el onboardingInput exacto (para que el reconciliador pueda reproducirlo). Best-effort: un fallo de bookkeeping nunca convierte el webhook en un 5xx (launch blocker A).

3.8 Hand-off de enqueue — enqueueOnboarding

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

Hace de puente entre un checkout liquidado y el orquestador Go. Nunca lanza — un fallo de hand-off no puede convertir el webhook de Stripe en un 5xx.

  • Si PROVISIONING_TRIGGER_URL o PROVISIONING_TRIGGER_SECRET no está definido → degrada de forma segura: emite customer.onboarding.enqueue {transport:"log", reason:"trigger_not_configured"} y devuelve { enqueued: false }. El reconciliador (o el orquestador cross-cloud) lo recoge.
  • En caso contrario: hace POST del wireBody en snake_case a la trigger URL con X-Provisioning-Signature = hex(HMAC-SHA256(body)), timeout de 10 s. No-2xx → { enqueued: false, reason: 'http_<status>' }; error de red → { enqueued: false, reason: 'fetch_error' }.

El POST /trigger/onboarding del orquestador (provisioning-orchestrator/cmd/provisioner/main.go::onboardingTriggerHandler) verifica el HMAC (constant-time, cuerpo capado a 64 KB), hace unmarshal de OnboardingInput, exige stripe_session_id + email, y llama a StartOnboarding. El WorkflowID es "onboarding-" + StripeSessionID (worker.go), de modo que un webhook reentregado deduplica a la misma ejecución de Temporal.

3.9 Saga de onboarding — OnboardingWorkflow

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

El workflow Temporal real. Opciones de activity: StartToCloseTimeout=30 s, retry con backoff (×2, tope 20 s). La saga entera está acotada por WorkflowTimeout = 15 min (onboarding_run.go) — suficiente para cubrir el presupuesto de retry de toda activity más la cadena de compensación, de modo que un timeout del lado del servidor nunca dispare en medio de la saga y se salte el rollback diferido.

12 activities (pasos 1–12; el paso 9b es el eco de vuelta):

# Activity Compensación apilada (LIFO)
1 KYCCheck (resultado limpio; un non-pass es una decisión de workflow, ErrKYCFailed, no reintentada)
2 MintDeploymentID (ULID dpl_) MarkDeploymentIDRolledBack
3 AllocateCell (GeoIP countryCode → cell más cercana) 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 vuelta) — POSTea el dpl_ id + cell + cuota al customer-app; último paso falible — (limpia la cadena en éxito)
10 SendWelcomeEmail (post 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.

El eco de vuelta LinkCustomerAccount (Activity 9b)

Archivo: internal/activities/real_provider_callback.go

POSTea {stripe_session_id, status:"provisioned", deployment_id, cell_id, tokens_quota} al POST /api/internal/provisioning/callback del customer-app, firmado por HMAC. Fail-closed cuando la URL/secret del callback no está cableada (ErrNotProvisioned). Un 404 (el customer-app aún no grabó la fila de provisioning_jobs — race del webhook) es ErrTransient, así Temporal reintenta; la fila aparece en segundos. Solo un 200 confirma el link.

Corre como el último paso falible, justo antes del email de bienvenida no-return, precisamente para que, si falla, la saga diferida se desenrolle con la cuenta nunca puesta en active (la fila de provisioning_jobs queda unprovisioned y el reconciliador re-drive) — en lugar de un medio-estado permanente.

El punto no-return + ReportProvisioningFailed

linked := true cambia tras el paso 9b. Luego rollback = nil limpia la cadena de compensación antes del email de bienvenida (audit V1): de lo contrario, un error transitorio de SendWelcomeEmail (ErrTransient ante un hipo de Postmark) desenrollaría la saga entera — revocando el cert, poniendo la cuota a cero, liberando slots — sobre un cliente que ya está active en el customer-app, sin nada que reparar (la fila de provisioning_jobs ya está provisioned, excluida del reconciliador).

Ante cualquier fallo terminal, la función diferida ejecuta la pila de compensación LIFO en un contexto desconectado (para que la cancelación del workflow no aborte el rollback) y luego — solo si !linked — dispara ReportProvisioningFailed (callback de status failedmarkProvisioningJobFailed) y SendProvisioningFailedEmail con un motivo genérico (nunca filtrando detalle de KYC/sanciones). Ambos eran código muerto, con cero call sites.

flowchart TD
    Start([Inicio del 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/>PUNTO NO-RETURN"]
    A9b -->|error| FAIL
    LINKED --> A10[10. SendWelcomeEmail]
    A10 -->|error| PROVISIONED_NOEMAIL["return error<br/>(provisionado; reenvío manual)<br/>SIN unwind"]
    A10 -->|ok| A11[11. AppendAuditChain best-effort]
    A11 --> A12[12. NotifyAdminHighValue si ≥ $500]
    A12 --> Done([OnboardingResult])

    KFAIL --> FAIL
    FAIL{{"diferido: err != nil"}}
    FAIL --> COMP["Contexto desconectado<br/>ejecuta pila 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["sin aviso de fallo<br/>(el cliente SÍ está provisionado)"]
    REPORT --> End([terminal])
    NOOP --> End
    PROVISIONED_NOEMAIL --> End

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

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

El receptor del eco de vuelta en el customer-app. Auth: HMAC-SHA256 sobre el cuerpo crudo (PROVISIONING_CALLBACK_SECRET preferido, con fallback a PROVISIONING_TRIGGER_SECRET), comparado en constant-time. Falla cerrado: sin secret → 503; firma inválida → 401.

  • status: "failed"markProvisioningJobFailed200.
  • status: "provisioned" → exige deployment_id + cell_id (si no, 400) → linkProvisionedAccount({stripeSessionId, deploymentId, cellId, tokens}) que liga el dpl_ id real a la cuenta (emparejada por stripe_session_id) y la pone en active. Si ningún job coincide con la sesión → 404, para que el orquestador reintente/escale en lugar de asumir éxito.

Este es el paso que retira el placeholder pending- del deployment_id: antes de que existiera el callback, el deployment_id de la cuenta quedaba pending- para siempre y activateAccount() era código muerto (launch blocker — Cluster A).

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

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

Cierra la brecha de durabilidad: un hand-off puede atascarse (trigger inalcanzable / cell down / no-2xx / saga falló a mitad). Auth: x-cron-secret (constant-time, SHA-256). Siempre devuelve 200 (reporta).

  • getStaleProvisioningJobs({staleBefore: now − 5 min, maxAttempts: 8, limit: 25}) — la ventana STALE_AFTER_MS es lo bastante larga para dejar que los retries propios del orquestador + el callback aterricen primero.
  • Para cada job con triggerPayload persistido, lo reproduce vía enqueueOnboarding (una fila sin payload — pre-migración 0039 — se salta, nunca se re-minta a partir de una conjetura), luego recordProvisioningJob incrementa los intentos. Un job más allá de MAX_ATTEMPTS=8 deja de reintentarse pero sigue visible (el gauge de "stuck" de /api/metrics + AlertManager paginan sobre él). Un job ligado por el callback mientras tanto está provisioned y sale del scan.

3.12 Descarga de la TBI + emisión de licencia

  • Descarga de la TBIGET /api/account/download/[downloadKey] (src/app/api/account/download/[downloadKey]/route.ts): sesión autenticada, cuenta activa (no-suspended/deleted) y chequeo de país OFAC/embargo (isSanctionedCountry403 unavailable genérico). Minta una URL presigned de S3 de 5 minutos para la imagen de bootstrap publicada (el bucket es privado) y hace 302-redirect. Rate limit 60/h por cuenta.
  • Emisión de licenciaPOST /api/license/issue (src/app/api/license/issue/route.ts): autenticada + verificada por MFA (!tfv403 mfa_required), rate limit 10/h/cuenta. signLicense({accountId, tier, validitySec}) (default 365 d, máx 730 d); persiste una fila licenses guardando solo jti + kid (nunca el token). El JWT se muestra una vez para que el operador lo pegue en la caja on-prem.

3.13 Instalación on-prem — primer arranque del bootstrap-controller

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

Primer arranque (state dir default /var/lib/tlsstress/bootstrap):

  1. Lee el license JWT de …/bootstrap/license.jwt (chmod 0600); vacío/ausente → Fatalf con una pista para pegar.
  2. Computa el fingerprint de hardware (internal/fingerprint): SHA-256 sobre el primero disponible entre /etc/machine-id, luego /sys/class/dmi/id/product_uuid, luego hostname + el primer MAC estable (no-virtual, sin docker/cni/veth), unidos por |. Los hosts de dev no-Linux (sin machine-id/DMI) caen a un fingerprint simulated que la nube vincula de forma idéntica.
  3. POST /api/install/manifest (Bearer license JWT) — verifica el JWT, cruza con licenses por revocación + mismatch de cuenta, vincula al fingerprint en la primera llamada (UPDATE … WHERE bound_fingerprint IS NULL atómico, de modo que una race de JWT robado no pueda hacer double-bind), opcionalmente hace enroll de la pubkey de uso L1, y devuelve los image refs de módulo por tier + URLs de nube + heartbeatIntervalSec=3600 + gracePeriodSec=86400. Los manifests se escriben bajo /var/lib/tlsstress/manifests/ (un systemd unit externo los aplica).
  4. Heartbeat inicial (estampa la ventana de gracia) + drain inicial de uso (no-op en el primer arranque).
  5. Entra en el bucle programado: heartbeat horario + usage report horario.

Un signer de attestation L1 (Ed25519 host-held, ADR 0104) se carga/crea; un lease broker L2 opcional puede gate las runs contra la nube en cuanto el heartbeat quede stale más allá de la ventana de gracia.

3.14 Bucle de metering — heartbeat + usage report

  • HeartbeatPOST /api/license/heartbeat (src/app/api/license/heartbeat/route.ts): Bearer license JWT; actualiza licenses.last_heartbeat_at + añade una fila license_heartbeats; impone el vínculo de fingerprint y la revocación; registra posture/quote TPM L3 (ADR 0104). Devuelve graceExpiresAt = now + 24 h. 24 h sin heartbeat → los módulos pasan a read-only.
  • Usage reportPOST /api/usage/report (src/app/api/usage/report/route.ts): Bearer license JWT; consumo de TSU append-only desde el reporter de spend on-prem.
  • Chequeos de licencia: jti existe, accountId coincide con claims.sub (defensa en profundidad), no revocada, fingerprint-bound (una licencia no vinculada nunca reporta — license_not_bound), fingerprint coincide.
  • Attestation L1 (ADR 0104): cuando una usagePubkey está enrolada y el modo ≠ off, todo report debe llevar un sobre firmado (licencia, fingerprint, rango de seq, nonce, digest de eventos). envelope_missing es tolerante en modo advisory; una regresión de seq, firma inválida o nonce reusado en un sobre firmado se rechaza hard incluso en modo advisory.
  • Insert + debit atómico (M1/A6): las filas de uso y el debit de TSU (spendTSU(... , tx)) commitan en una transacción, de-dupadas por (license_id, module, client_seq). InsufficientBalanceError revierte la unidad entera y devuelve 402 quotaExceeded (el controller pausa + pide top-up); AccountSuspendedError403 account_suspended (reconocido por el IsLicenseRejected del controller, que detiene el bucle). El saldo restante se lee del ledger UTXO en todo camino (fuente única de verdad, C1).

4. Máquina de estados del status de la cuenta

stateDiagram-v2
    [*] --> signup: createCustomerAndUser
    signup --> email_pending: email de verificación enviado
    email_pending --> kyc_pending: GET /api/auth/verify (markEmailVerified)
    kyc_pending --> active: onboarding tier1 (activateIfKycPending) — camino FREE
    kyc_pending --> active: callback de provisioning linkProvisionedAccount — camino PAGO
    email_pending --> active: webhook Stripe applyTierFromStripe (paid-signup-first)
    active --> suspended: complianceBlock / refund / dispute / past_due
    suspended --> active: revisión de ops (manual)
    active --> churned: suscripción eliminada
    active --> deletion_pending: DSAR (GDPR Art.17)
    deletion_pending --> deleted: cron process-deletions
    suspended --> [*]
    deleted --> [*]
Transición Disparador Código
signup → email_pending email de verificación 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 en webhook / callback de saga stripe/webhook::applyTierFromStripe, provisioning/callback::linkProvisionedAccount
* → suspended compliance / refund / dispute / dunning stripe/webhook::complianceBlock, handleChargeRefunded, handleDisputeCreated

5. Matriz de idempotencia, compensación y modos de fallo

Etapa Clave de idempotencia Modo de fallo → comportamiento
Signup unicidad de email (DB) race de conflicto → 201 neutro
Verify UPDATE used_at de un solo disparo token reusado/expirado → 302 /verify-error
Verify MFA (backup) burn con FOR UPDATE en tx mismo código concurrente → solo uno hace burn
Mint de onboarding sourceRef por tier reenvío de tier completado → no minta nada
Webhook Stripe claimStripeWebhookEvent (id del evento Stripe) throw del handler → 500, Stripe reentrega; duplicada → 200 duplicate
Mint de refill de suscripción stripeInvoiceId (por invoice) retry → sin crédito doble
Enqueue WorkflowID onboarding-{sessionId} trigger down → enqueued:false, reconciliador re-drive
Activities de la saga IdempotencyKey(wfID, name, attempt) error de activity → retry Temporal 5×; terminal → compensación LIFO
Callback emparejado por stripe_session_id sin job → 404, orquestador reintenta
Usage report (license_id, module, client_seq) batch duplicado → 0 filas, 0 debit

6. Configuración (entorno)

Variable Usada por Efecto cuando no está definida
STRIPE_WEBHOOK_SECRET webhook 503 webhook_not_configured
PROVISIONING_TRIGGER_URL / PROVISIONING_TRIGGER_SECRET enqueueOnboarding + trigger del orquestador degrada seguro → enqueue solo por log; reconciliador re-drive
PROVISIONING_CALLBACK_SECRET (fallback a …TRIGGER_SECRET) callback de provisioning 503 (falla cerrado)
CRON_SECRET cron de reconcile 503 cron_not_configured
KMS_TOTP_KEY_ARN verify de MFA salta el re-cifrado de secret legacy
USAGE_ATTESTATION_MODE usage report semántica advisory (vs enforce)
ECR_REGISTRY install manifest sin registryAuth (no-fatal)
APP_BASE_URL / PUBLIC_APP_URL enlaces de verify/magic default https://app.tlsstress.art

7. Referencias cruzadas

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