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
1 change: 1 addition & 0 deletions .agent/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ Machine-readable descriptions of every framework sub-package, generated by `univ
| [univeros/test-reporter](packages/test-reporter.md) | `Altair\TestReporter` | AI-native PHPUnit reporter: structured JSON output mapped to the production source under test. |
| [univeros/tinker](packages/tinker.md) | `Altair\Tinker` | bin/altair tinker — an interactive PsySH REPL with the DI container in scope and a doctor-style preamble of what's wired. A local debugging tool for developers, not an agent surface. |
| [univeros/validation](packages/validation.md) | `Altair\Validation` | The Altair Validation package. |
| [univeros/webhooks](packages/webhooks.md) | `Altair\Webhooks` | First-class webhook framework for Univeros: signing primitives, inbound verify middleware, and an outbound dispatcher with retry / dead-letter / replay. |
5 changes: 5 additions & 0 deletions .agent/packages/scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@
- `TypeScriptEmitter` _(final)_ — implements `EmitterInterface`
- `UnmappableSchemaException` _(final)_ — implements `Stringable`, `Throwable`
- `Validator`
- `WebhookDispatcherBindingEmitter`
- `WebhookSpec` _(final)_
- `WriteOutcome` _(final)_
- `WriteStatus` _(final)_ — implements `BackedEnum`, `UnitEnum`

Expand All @@ -108,6 +110,7 @@
- `tests/Scaffold/Determinism/SdkEmitterDeterminismTest.php`
- `tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php`
- `tests/Scaffold/Emitter/ActionEmitterTest.php`
- `tests/Scaffold/Emitter/ActionEmitterWebhookTest.php`
- `tests/Scaffold/Emitter/DomainStubEmitterTest.php`
- `tests/Scaffold/Emitter/EntityEmitterTest.php`
- `tests/Scaffold/Emitter/HandlerEmitterTest.php`
Expand All @@ -122,6 +125,7 @@
- `tests/Scaffold/Emitter/ResponderEmitterTest.php`
- `tests/Scaffold/Emitter/RouteEmitterTest.php`
- `tests/Scaffold/Emitter/TestEmitterTest.php`
- `tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php`
- `tests/Scaffold/Journal/Cli/CommandsTest.php`
- `tests/Scaffold/Journal/Differ/FileDifferTest.php`
- `tests/Scaffold/Journal/JournalEntryTest.php`
Expand All @@ -146,6 +150,7 @@
- `tests/Scaffold/Spec/PersistenceParserTest.php`
- `tests/Scaffold/Spec/PersistenceValidatorTest.php`
- `tests/Scaffold/Spec/ValidatorTest.php`
- `tests/Scaffold/Spec/WebhookParserTest.php`

## Related packages

Expand Down
73 changes: 73 additions & 0 deletions .agent/packages/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# univeros/webhooks · Altair\Webhooks

**Purpose:** First-class webhook framework for Univeros: signing primitives, inbound verify middleware, and an outbound dispatcher with retry / dead-letter / replay.

## Public contracts

| Interface | Method | Returns | Notes |
|---|---|---|---|
| `DeliveryStoreInterface` | `findById(string)` | `Delivery\|null` | |
| | `findFailed(int)` | `array` | |
| | `record(Delivery)` | `void` | |
| | `update(Delivery)` | `void` | |
| `InboundDeduplicatorInterface` | `claim(string, int)` | `bool` | |
| | `release(string)` | `void` | |
| `SecretResolverInterface` | `resolve(string)` | `string` | |
| `SignerInterface` | `name()` | `string` | |
| | `sign(string, string)` | `string` | |
| | `verify(string, string, string)` | `bool` | |

## Concrete classes

- `AbstractHmacSigner` _(abstract)_ — implements `SignerInterface`
- `ActionAwareWebhookVerifyMiddleware` _(final)_ — implements `MiddlewareInterface`
- `Delivery` _(final)_
- `DeliveryStatus` _(final)_ — implements `BackedEnum`, `UnitEnum`
- `DurationParser` _(final)_
- `Ed25519Signer` _(final)_ — implements `SignerInterface`
- `EnvSecretResolver` _(final)_ — implements `SecretResolverInterface`
- `HmacSha256Signer` _(final)_ — implements `SignerInterface`
- `HmacSha512Signer` _(final)_ — implements `SignerInterface`
- `InMemoryDeduplicator` _(final)_ — implements `InboundDeduplicatorInterface`
- `InMemoryDeliveryStore` _(final)_ — implements `DeliveryStoreInterface`
- `RedisDeduplicator` _(final)_ — implements `InboundDeduplicatorInterface`
- `RedisDeliveryStore` _(final)_ — implements `DeliveryStoreInterface`
- `RetryPolicy` _(final)_
- `SignerRegistry` _(final)_
- `WebhookDispatcher` _(final)_
- `WebhookHandler` _(final)_
- `WebhookMessage` _(final)_
- `WebhookReplayCommand` _(final)_ — implements `SignalableCommandInterface`
- `WebhookShowFailedCommand` _(final)_ — implements `SignalableCommandInterface`
- `WebhookVerifyMiddleware` _(final)_ — implements `MiddlewareInterface`

## Tests as documentation

- `tests/Webhooks/Cli/WebhookReplayCommandTest.php`
- `tests/Webhooks/Cli/WebhookShowFailedCommandTest.php`
- `tests/Webhooks/Dispatcher/RetryPolicyTest.php`
- `tests/Webhooks/Dispatcher/WebhookDispatcherTest.php`
- `tests/Webhooks/Dispatcher/WebhookHandlerTest.php`
- `tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php`
- `tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php`
- `tests/Webhooks/Signing/Ed25519SignerTest.php`
- `tests/Webhooks/Signing/EnvSecretResolverTest.php`
- `tests/Webhooks/Signing/HmacSha256SignerTest.php`
- `tests/Webhooks/Signing/HmacSha512SignerTest.php`
- `tests/Webhooks/Signing/SignerRegistryTest.php`
- `tests/Webhooks/Storage/DeliveryTest.php`
- `tests/Webhooks/Storage/InMemoryDeduplicatorTest.php`
- `tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php`
- `tests/Webhooks/Storage/RedisDeduplicatorTest.php`
- `tests/Webhooks/Storage/RedisDeliveryStoreTest.php`

## Related packages

- `psr/http-client`
- `psr/http-factory`
- `psr/http-message`
- `psr/http-server-handler`
- `psr/http-server-middleware`
- `univeros/configuration`
- `univeros/container`
- `univeros/messaging`
41 changes: 40 additions & 1 deletion src/Altair/Scaffold/Emitter/ActionEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Altair\Scaffold\Spec\Ast\IdempotencySpec;
use Altair\Scaffold\Spec\Ast\Spec;
use Altair\Scaffold\Spec\Ast\WebhookSpec;
use Altair\Scaffold\Templating\PhpHeader;

/**
Expand All @@ -39,6 +40,7 @@ public function emit(Spec $spec): EmittedFile
$responderFqcn = $this->naming->responderFqcn($spec);
$domainFqcn = $spec->domain->class;
$idempotencyAccessor = $this->renderIdempotencyAccessor($spec->idempotency);
$webhookAccessor = $this->renderWebhookAccessor($spec->webhook);

$header = PhpHeader::render($namespace);
$body = <<<PHP
Expand All @@ -60,7 +62,7 @@ public function __construct()
input: \\{$inputFqcn}::class,
);
}
{$idempotencyAccessor}}
{$idempotencyAccessor}{$webhookAccessor}}

PHP;

Expand Down Expand Up @@ -109,4 +111,41 @@ public static function idempotency(): array

PHP;
}

/**
* Renders the static `webhook()` accessor for inbound specs so the host
* application's ActionAwareWebhookVerifyMiddleware can configure
* verification from spec metadata. Empty string for outbound or absent
* blocks so unrelated scaffolds stay byte-for-byte identical.
*/
private function renderWebhookAccessor(?WebhookSpec $webhook): string
{
if (!$webhook instanceof WebhookSpec || $webhook->direction !== WebhookSpec::DIRECTION_IN) {
return '';
}

$direction = var_export($webhook->direction, true);
$signing = var_export($webhook->signing, true);
$secret = var_export($webhook->secretName, true);
$signatureHeader = var_export($webhook->signatureHeader, true);
$timestampHeader = var_export($webhook->timestampHeader, true);
$eventIdHeader = var_export($webhook->eventIdHeader, true);
$dedupeTtl = var_export($webhook->dedupeTtl, true);
$timestampWindow = var_export($webhook->timestampWindow, true);

return <<<PHP

/**
* Inbound webhook policy for this endpoint. Consumed by the host
* application's ActionAwareWebhookVerifyMiddleware.
*
* @return array{direction: string, signing: string, secret_name: string, signature_header: string, timestamp_header: string, event_id_header: string, dedupe_ttl: string, timestamp_window: string}
*/
public static function webhook(): array
{
return ['direction' => {$direction}, 'signing' => {$signing}, 'secret_name' => {$secret}, 'signature_header' => {$signatureHeader}, 'timestamp_header' => {$timestampHeader}, 'event_id_header' => {$eventIdHeader}, 'dedupe_ttl' => {$dedupeTtl}, 'timestamp_window' => {$timestampWindow}];
}

PHP;
}
}
6 changes: 6 additions & 0 deletions src/Altair/Scaffold/Emitter/EmissionPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Altair\Scaffold\Spec\Ast\PersistenceSpec;
use Altair\Scaffold\Spec\Ast\Spec;
use Altair\Scaffold\Spec\Ast\WebhookSpec;

/**
* Runs every emitter against a Spec and returns the list of EmittedFile
Expand All @@ -37,6 +38,7 @@ public function __construct(
private readonly MessageEmitter $messageEmitter = new MessageEmitter(),
private readonly HandlerEmitter $handlerEmitter = new HandlerEmitter(),
private readonly HandlerTestEmitter $handlerTestEmitter = new HandlerTestEmitter(),
private readonly WebhookDispatcherBindingEmitter $webhookDispatcherBindingEmitter = new WebhookDispatcherBindingEmitter(),
) {}

/**
Expand Down Expand Up @@ -69,6 +71,10 @@ public function build(Spec $spec): array
$files[] = $this->handlerTestEmitter->emit($queue);
}

if ($spec->webhook instanceof WebhookSpec && $spec->webhook->isOutbound()) {
$files[] = $this->webhookDispatcherBindingEmitter->emit($spec);
}

return $files;
}
}
1 change: 1 addition & 0 deletions src/Altair/Scaffold/Emitter/EmittedFileKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ enum EmittedFileKind: string
case Handler = 'handler';
case HandlerTest = 'handler-test';
case Spec = 'spec';
case WebhookDispatcher = 'webhook-dispatcher';
}
15 changes: 15 additions & 0 deletions src/Altair/Scaffold/Emitter/Naming.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ public function messagePath(string $messageFqcn): string
return $this->classFileRelativePath($messageFqcn);
}

public function webhookDispatcherShortName(Spec $spec): string
{
return $spec->artifactName() . 'WebhookDispatcher';
}

public function webhookDispatcherFqcn(Spec $spec): string
{
return $this->appNamespace . '\\Webhooks\\' . $this->webhookDispatcherShortName($spec);
}

public function webhookDispatcherPath(Spec $spec): string
{
return $this->classFileRelativePath($this->webhookDispatcherFqcn($spec));
}

public function handlerFqcn(string $messageFqcn): string
{
$namespace = $this->namespaceOf($messageFqcn);
Expand Down
114 changes: 114 additions & 0 deletions src/Altair/Scaffold/Emitter/WebhookDispatcherBindingEmitter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

/*
* This file is part of the univeros/framework
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Altair\Scaffold\Emitter;

use Altair\Scaffold\Spec\Ast\Spec;
use Altair\Scaffold\Spec\Ast\WebhookSpec;
use Altair\Scaffold\Templating\PhpHeader;
use LogicException;

/**
* Emits an outbound webhook dispatcher binding for a spec carrying
* `webhook: { direction: out }`.
*
* The generated class wraps {@see \Altair\Webhooks\Dispatcher\WebhookDispatcher}
* with the signing scheme, retry policy, and dead-letter transport declared in
* the spec, pre-filling the signer when the host dispatches. Lives under
* `app/Webhooks/` — a clear namespace for these bindings.
*/
class WebhookDispatcherBindingEmitter
{
public function __construct(private readonly Naming $naming = new Naming()) {}

public function emit(Spec $spec): EmittedFile
{
$webhook = $spec->webhook;
if (!$webhook instanceof WebhookSpec) {
throw new LogicException('WebhookDispatcherBindingEmitter requires a webhook block.');
}

$shortName = $this->naming->webhookDispatcherShortName($spec);
$namespace = $this->namespaceOf($this->naming->webhookDispatcherFqcn($spec));

$signing = var_export($webhook->signing, true);
$backoff = var_export($webhook->retryBackoff, true);
$deadLetter = var_export($webhook->deadLetterTransport, true);
$maxAttempts = $webhook->retryMaxAttempts;
$baseDelaySeconds = $this->toSeconds($webhook->retryBaseDelay);

$header = PhpHeader::render($namespace);
$body = <<<PHP
use Altair\\Webhooks\\Dispatcher\\RetryPolicy;
use Altair\\Webhooks\\Dispatcher\\WebhookDispatcher;
use Altair\\Webhooks\\Storage\\Delivery;

/**
* Generated outbound webhook binding for {$spec->endpoint->method} {$spec->endpoint->path}.
*
* Wraps WebhookDispatcher with the signing scheme + retry policy the
* spec declared; the host dispatches through this binding.
*/
final readonly class {$shortName}
{
public const string SIGNING = {$signing};

public const ?string DEAD_LETTER_TRANSPORT = {$deadLetter};

public function __construct(private WebhookDispatcher \$dispatcher) {}

public function retryPolicy(): RetryPolicy
{
return new RetryPolicy(maxAttempts: {$maxAttempts}, backoff: {$backoff}, baseDelaySeconds: {$baseDelaySeconds});
}

/**
* @param array<array-key, mixed>|string \$payload
*/
public function dispatch(string \$eventName, array|string \$payload, string \$subscriberUrl, string \$secretName): Delivery
{
return \$this->dispatcher->dispatch(\$eventName, \$payload, \$subscriberUrl, \$secretName, self::SIGNING);
}
}

PHP;

return new EmittedFile(
relativePath: $this->naming->webhookDispatcherPath($spec),
contents: $header . $body,
kind: EmittedFileKind::WebhookDispatcher,
);
}

private function toSeconds(string $duration): int
{
if (preg_match('/^(\d+)(ms|s|m|h|d)$/', $duration, $match) !== 1) {
return 30;
}

$value = (int) $match[1];

return match ($match[2]) {
'ms' => $value > 0 ? max(1, (int) ceil($value / 1000)) : 0,
'm' => $value * 60,
'h' => $value * 3_600,
'd' => $value * 86_400,
default => $value,
};
}

private function namespaceOf(string $fqcn): string
{
$pos = strrpos($fqcn, '\\');

return $pos === false ? '' : substr($fqcn, 0, $pos);
}
}
1 change: 1 addition & 0 deletions src/Altair/Scaffold/Spec/Ast/Spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function __construct(
public ?PersistenceSpec $persistence = null,
public array $queue = [],
public ?IdempotencySpec $idempotency = null,
public ?WebhookSpec $webhook = null,
) {}

/**
Expand Down
Loading
Loading