Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y

## [Unreleased]

### Changed

- **Over the CQRS bus, `detail` is the cause's own sentence.** A refusal raised inside a handler — a `409` from the
domain, a `#[PreAuthorize]` denial — now publishes the same problem document it publishes thrown from a
controller, `detail` included, instead of prefixing it with `Processing command [<class>] failed:`; the HTML error
page's lede follows. The wrapper's `getMessage()` keeps the prefix for the log. The seam is the kernel's:
`FireflyException::detail()` (`getMessage()` by default) is what `ErrorResponse::fromException()` publishes, and
`withDetail(?string)` replaces it, with `null` meaning the exception holds no sentence written for a client.

### Security

- **A command or query handler's unhandled throwable no longer reaches the wire.** `CommandProcessingException` and
`QueryProcessingException` are `FireflyException`s whose message is `Processing command [<class>] failed: <the
cause's message>`, and the web layer published that message as `detail` — so a handler's `QueryException` put its
SQL and bindings, and the command's class name, into a 500 problem document with `firefly.web.problem.disclose`
off, where the same throwable thrown from a controller answered with the opaque sentence. A wrapper of a cause that
is not a `FireflyException` now answers exactly like that controller: `An unexpected error occurred. It has been
logged; quote reference <traceId> if you report it.` with disclosure off, and the cause's own message (no prefix)
with it on, keeping its own `COMMAND_PROCESSING_ERROR`/`QUERY_PROCESSING_ERROR` code. The same rule now covers the
event-sourcing mapper's `EventSerializationException` for an event constructor that threw, the orchestration
engine's `ORCHESTRATION_CONDITION_FAILED`, and the orchestration REST surface, which published any refusal's message.

## [26.10.2] - 2026-10-13

### Added
Expand Down
11 changes: 6 additions & 5 deletions book/src-es/04-first-http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se
public static function toFireflyException(Throwable $e, bool $disclose = true, ?string $reference = null): FireflyException
{
return match (true) {
$e instanceof FireflyException && $e->detail() === null => self::withheld($e, $disclose, $reference),
$e instanceof FireflyException => $e,
self::isExecutionTimeLimit($e) => self::executionTimeExceeded($e, $reference),
$e instanceof MethodNotAllowedHttpException => self::methodNotAllowed($e),
Expand All @@ -503,9 +504,9 @@ Lo que `render()` deliberadamente **no** decide es en qué `FireflyException` se
}
```

Cinco brazos que cubren cuatro casos — el `405` tiene un brazo propio solo para que los verbos que permite puedan sacarse de la frase del enrutador y llevarse a un miembro `allowed` y a la cabecera `Allow` — y todos ellos terminan de vuelta en la misma llamada `ErrorResponse::fromException(...)->toArray()`, así que la forma de la carga útil nunca depende de qué brazo la produjo.
Seis brazos que cubren cinco casos — el `405` tiene un brazo propio solo para que los verbos que permite puedan sacarse de la frase del enrutador y llevarse a un miembro `allowed` y a la cabecera `Allow` — y todos ellos terminan de vuelta en la misma llamada `ErrorResponse::fromException(...)->toArray()`, así que la forma de la carga útil nunca depende de qué brazo la produjo.

Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar.
Una `FireflyException` — o una de sus subclases tipadas, como `ResourceNotFoundException` — se devuelve intacta y se renderiza con su propio `httpStatus()`, porque su mensaje lo escribió tu aplicación *para* el cliente; ese es justamente el sentido de la taxonomía. Lo que llega al cable es el `detail()` de la excepción, que es ese mensaje salvo que la excepción diga otra cosa — y el primer brazo es para la que lo dice: una `FireflyException` cuyo `detail()` es `null` no lleva ninguna frase para el cliente (el envoltorio del bus CQRS sobre una `QueryException` es una, Capítulo 7), así que conserva su propio código, estado, categoría y miembros de extensión, pero toma su frase exactamente como el último brazo se la daría al throwable que envuelve. El propio límite de tiempo de ejecución de PHP se nombra aparte y responde `503` con una cabecera `Retry-After`, porque «el servidor detuvo esta petición a los N segundos» es algo sobre lo que quien llama puede actuar y un `500` desnudo no lo es. Una excepción HTTP de Laravel/Symfony — una URL que no coincide con ninguna ruta, un verbo que una ruta no acepta — conserva su **código de estado real**, así que una ruta no coincidente sigue respondiendo `404` y nunca un `500` engañoso; solo se reemplaza la redacción del propio enrutador por una frase escrita para una persona, y los verbos permitidos de un `405` pasan a un miembro `allowed` y a la cabecera `Allow`, donde un cliente puede leerlos sin analizar inglés. Cualquier otra cosa es un accidente, y `$disclose` — `firefly.web.problem.disclose`, por defecto `false` — decide si su mensaje puede publicarse siquiera: con él apagado el cuerpo lleva una frase fija que nombra la misma referencia que lleva el `traceId` del documento, y el mensaje real se queda en el log, que es donde el SQL de una `QueryException` y sus enlaces deben estar.

Pedir un monedero que nunca se abrió se renderiza así. Fíjate en `instance`: el RFC 9457 §3.1.5 lo define como una **referencia** URI, y una referencia relativa se resuelve contra la URI base del documento — así que el `api/v1/wallets/wlt-999` pelado que devuelve `$request->path()`, servido desde `/api/v1/wallets/wlt-999`, identificaría `/api/v1/api/v1/wallets/wlt-999`. LaraFly publica la forma relativa a la raíz, que es el único trabajo de `ProblemMapper::instanceFor()`, y un cliente puede compararla con la ruta que pidió. Fíjate también en `type`: el RFC 9457 §3.1.1 dice que un `type` ausente *es* `about:blank`, y LaraFly lo escribe en vez de dejar que el lector tenga que saberlo — apunta `firefly.web.problem.type-uri` a una URI base y el `code` estable deriva uno real y abrible.

Expand Down Expand Up @@ -557,7 +558,7 @@ Un intento de retiro se rechaza por dos vías distintas, y las dos **no dan el m
"code": "AUTHENTICATION_FAILED",
"category": "security",
"severity": "warning",
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: Authentication is required.",
"detail": "Authentication is required.",
"type": "about:blank",
"instance": "/api/v1/wallets/wlt-1/withdraw",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
Expand All @@ -575,7 +576,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
"code": "ACCESS_DENIED",
"category": "security",
"severity": "warning",
"detail": "Processing command [Lumen\\Application\\Command\\Withdraw] failed: You do not have permission to do this.",
"detail": "You do not have permission to do this.",
"type": "about:blank",
"instance": "/api/v1/wallets/wlt-1/withdraw",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
Expand All @@ -585,7 +586,7 @@ El `403` queda reservado para un principal que **sí** ha iniciado sesión y aun
}
```

Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado, la categoría y los miembros de extensión de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus, y el 403 nombra las mismas `requiredAuthorities` que nombraría un método de controlador rechazado. Solo `detail` delata que pasó por el bus: el envoltorio antepone a la frase de la causa el comando que estaba procesando. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.
Los dos se producen sin ningún cambio de código en `WalletController::withdraw()`: el bus envuelve la excepción de seguridad que haya levantado el evaluador en una `CommandProcessingException` que copia el código, el estado, la categoría y los miembros de extensión de la causa, así que el cable lleva la identidad de la causa y no un error genérico del bus, y el 403 nombra las mismas `requiredAuthorities` que nombraría un método de controlador rechazado. Ni siquiera `detail` delata que pasó por el bus: es la propia frase de la causa, palabra por palabra. El *mensaje* del envoltorio sigue nombrando el comando que estaba procesando — `Processing command [Lumen\Application\Command\Withdraw] failed: …` — pero esa línea es para el log, y el Capítulo 7 explica por qué se mantienen separados. El Capítulo 10 vuelve sobre este par — y sobre las dos pruebas distribuidas que lo fijan — en detalle.

### Manejar una excepción tú mismo

Expand Down
5 changes: 4 additions & 1 deletion book/src-es/07-cqrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,11 +414,14 @@ final class CommandProcessingException extends CqrsException
$cause instanceof FireflyException ? $cause->extensions() : [],
$cause instanceof FireflyException ? $cause->title() : null,
);

// …
$this->withDetail($cause instanceof FireflyException ? $cause->detail() : null);
}
}
```

Tres cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`, y también sus miembros de extensión RFC 9457 y su título, razón por la cual un `#[PreAuthorize]` rechazado sigue nombrando sus `requiredAuthorities` en el documento de problema después de que el bus lo envuelva (el Capítulo 4 muestra ese documento). Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma.
Cuatro cosas merecen destacarse. Primero, la causa siempre está disponible en `getPrevious()` — el quinto argumento del constructor hacia el padre — así que nada del fallo original se pierde, solo se envuelve. Segundo, esto **preserva la categoría**: cuando la causa es una `FireflyException` (todas las excepciones del kernel lo son), su propio `httpStatus()`/`category()`/`severity()` se copian sobre el envoltorio en vez de recaer en un genérico `500`/`Internal`, y también sus miembros de extensión RFC 9457 y su título, razón por la cual un `#[PreAuthorize]` rechazado sigue nombrando sus `requiredAuthorities` en el documento de problema después de que el bus lo envuelva (el Capítulo 4 muestra ese documento). Una `ResourceNotFoundException` (404, negocio) lanzada por un id de monedero desconocido sigue renderizándose como una respuesta de detalles de problema 404 incluso después de ser envuelta — el bus añade contexto, nunca enmascara el fallo. Tercero, `send()` comprueba `$e instanceof CommandProcessingException` antes de envolver, así que un despacho de comando anidado (un manejador que a su vez llama a `$commands->send(...)`) nunca queda envuelto dos veces en dos capas de la misma excepción. `QueryProcessingException` es el gemelo del lado de lectura, idéntico en forma. Cuarto, el mensaje y el `detail` son **dos frases distintas**. El mensaje — `Processing command [Lumen\Application\Command\Withdraw] failed: …` — nombra el comando y cita la causa al pie de la letra, que es lo que quiere una línea de log. El documento de problema publica en su lugar `detail()`, y `withDetail()` lo fija al de la causa: una causa redactada llega al cliente palabra por palabra, exactamente como si la hubiera lanzado un controlador, y una causa que *no* es una `FireflyException` — una `QueryException` con su SQL y sus parámetros, un `TypeError` que nombra una ruta del servidor — no tiene ninguna frase para el cliente, así que el detalle es `null` y la capa web responde con la misma frase opaca que da a ese mismo throwable lanzado desde un controlador (o, con `firefly.web.problem.disclose=true`, el propio mensaje de la causa sin el prefijo). El envoltorio conserva su propio código `500 COMMAND_PROCESSING_ERROR`; solo se retiene la frase.

`HandlerRegistry` lanza sus propias excepciones, más estrechas, para un error de **cableado** en vez de un fallo de negocio — un mensaje sin mapear enviado a través del bus:

Expand Down
5 changes: 3 additions & 2 deletions book/src-es/10-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -1165,10 +1165,11 @@ it('allows an authorized withdraw and renders an overdraw as 409 problem+json',
$this->postJson("/api/v1/wallets/{$id}/withdraw", ['amount_minor' => 999999])
->assertStatus(409)
// …
->assertJsonPath('category', 'business');
->assertJsonPath('category', 'business')
->assertJsonPath('detail', 'cannot withdraw 9999.99 EUR; balance is 20.00 EUR');
```

La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El principal es `owner-4` y el monedero se abre para `owner-4`, y eso es lo que permite que `hasPermission(#command, 'debit')` conceda el cargo. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes.
La autoridad concedida es `'ROLE_WALLET_OWNER'` — ya con el prefijo `ROLE_` incorporado — y la expresión de `WithdrawHandler` llama a `hasRole('WALLET_OWNER')`, que `SecurityExpressionRoot::hasRole()` normaliza a la cadena idéntica `'ROLE_WALLET_OWNER'` antes de comprobar la pertenencia; las dos formas de escribirlo se encuentran en el medio. El principal es `owner-4` y el monedero se abre para `owner-4`, y eso es lo que permite que `hasPermission(#command, 'debit')` conceda el cargo. El `409` de la segunda llamada es el contraste importante: la autorización y la validación de dominio son dos capas genuinamente independientes — que se te *permita* retirar no dice nada sobre si la retirada en sí es *válida*, y la propia invariante de no-sobregiro de `Wallet::withdraw()` (Capítulo 6) sigue aplicándose exactamente igual que antes. Su `detail` es la frase del dominio palabra por palabra: el envoltorio del bus guarda la clase del comando para el log y publica la frase propia de la causa.

`samples/lumen/tests/Application/TransferSecurityTest.php` demuestra el mismo guardián directamente contra el `CommandBus`, con el patrón de desenvolver mediante `getPrevious()` que introdujo el Capítulo 7:

Expand Down
Loading
Loading