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→ orquestadorPOST /trigger/onboarding. - Eco de vuelta — orquestador
LinkCustomerAccount/ReportProvisioningFailed→ customer-appPOST /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 + passwordChangedAt →
createVerificationToken(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 sí 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: signup → email_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 defailedLoginCount). - Gate de estado de la cuenta:
suspended/deleted→401 account_unavailable;email_pending/signup→403 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 veredictoblockdeniega incluso con la contraseña correcta (403 risk_blocked); fail-open al camino de MFA obligatorio ante un error del engine.
Respuesta:
* totpEnabled=true → 200 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 porclaimTotpStep(userId, step)(AUTH-3): un código criptográficamente válido es de un solo uso por ventana de 30 s; un replay logueamfa.totp_replay_blockedy 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 conFOR 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:
- 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); una cuentasuspendedo KYCrejected→403 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:
- Exige
stripe-signature;503siSTRIPE_WEBHOOK_SECRETno está definido. stripe.webhooks.constructEvent(..., SIGNING_TOLERANCE_SECS=300); firma inválida →400.- Claim de idempotencia
claimStripeWebhookEvent(entrega at-least-once): una duplicada devuelve200 { duplicate: true }; un intento previo no finalizado se reprocesa (isRetry). dispatch(event)→finalizeStripeWebhookEvent(outcomeapplied|unhandled|failed|ignored). Un throw del handler persistefailedy devuelve500, 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, devuelveignored(nunca5xx, 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 aactive(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¶
- Ignora
payment_status='unpaid'(los métodos asíncronos acreditan después enasync_payment_succeeded). - Matching de cuenta en tres pasos:
stripe_customer_id→customer_email→ auto-provisión (autoCreateAccountFromStripe, paid-signup-first; crea una cuenta-cáscaraemail_pendingy dispara un magic-link víasendOnboardingMagicLink, TTL de 15 min). complianceBlock(arriba).- 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 — logCRITICAL, se deja para mapeo manual (audit STRIPE-1); la saga igual provisiona. applyTierFromStripe(tier + cuota mensual de tokens).enqueueOnboarding(el hand-off de la saga, §3.8).recordProvisioningJob— persiste de forma durable el hand-off enprovisioning_jobscon elonboardingInputexacto (para que el reconciliador pueda reproducirlo). Best-effort: un fallo de bookkeeping nunca convierte el webhook en un5xx(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_URLoPROVISIONING_TRIGGER_SECRETno está definido → degrada de forma segura: emitecustomer.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
POSTdelwireBodyen snake_case a la trigger URL conX-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
5× 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 failed → markProvisioningJobFailed)
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"→markProvisioningJobFailed→200.status: "provisioned"→ exigedeployment_id+cell_id(si no,400) →linkProvisionedAccount({stripeSessionId, deploymentId, cellId, tokens})que liga eldpl_id real a la cuenta (emparejada porstripe_session_id) y la pone enactive. 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 ventanaSTALE_AFTER_MSes lo bastante larga para dejar que los retries propios del orquestador + el callback aterricen primero.- Para cada job con
triggerPayloadpersistido, lo reproduce víaenqueueOnboarding(una fila sin payload — pre-migración 0039 — se salta, nunca se re-minta a partir de una conjetura), luegorecordProvisioningJobincrementa los intentos. Un job más allá deMAX_ATTEMPTS=8deja 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áprovisionedy sale del scan.
3.12 Descarga de la TBI + emisión de licencia¶
- Descarga de la TBI —
GET /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 (isSanctionedCountry→403 unavailablegenérico). Minta una URL presigned de S3 de 5 minutos para la imagen de bootstrap publicada (el bucket es privado) y hace302-redirect. Rate limit 60/h por cuenta. - Emisión de licencia —
POST /api/license/issue(src/app/api/license/issue/route.ts): autenticada + verificada por MFA (!tfv→403 mfa_required), rate limit 10/h/cuenta.signLicense({accountId, tier, validitySec})(default 365 d, máx 730 d); persiste una filalicensesguardando solojti+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):
- Lee el license JWT de
…/bootstrap/license.jwt(chmod 0600); vacío/ausente →Fatalfcon una pista para pegar. - 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, sindocker/cni/veth), unidos por|. Los hosts de dev no-Linux (sin machine-id/DMI) caen a un fingerprintsimulatedque la nube vincula de forma idéntica. POST /api/install/manifest(Bearer license JWT) — verifica el JWT, cruza conlicensespor revocación + mismatch de cuenta, vincula al fingerprint en la primera llamada (UPDATE … WHERE bound_fingerprint IS NULLató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).- Heartbeat inicial (estampa la ventana de gracia) + drain inicial de uso (no-op en el primer arranque).
- 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¶
- Heartbeat —
POST /api/license/heartbeat(src/app/api/license/heartbeat/route.ts): Bearer license JWT; actualizalicenses.last_heartbeat_at+ añade una filalicense_heartbeats; impone el vínculo de fingerprint y la revocación; registra posture/quote TPM L3 (ADR 0104). DevuelvegraceExpiresAt = now + 24 h. 24 h sin heartbeat → los módulos pasan a read-only. - Usage report —
POST /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:
jtiexiste,accountIdcoincide conclaims.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
usagePubkeyestá enrolada y el modo ≠off, todo report debe llevar un sobre firmado (licencia, fingerprint, rango de seq, nonce, digest de eventos).envelope_missinges 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).InsufficientBalanceErrorrevierte la unidad entera y devuelve402 quotaExceeded(el controller pausa + pide top-up);AccountSuspendedError→403 account_suspended(reconocido por elIsLicenseRejecteddel 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