From bdc617d48e63b2a540ba2508f258602c167b3c6f Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 12 Mar 2026 14:17:21 +0000 Subject: [PATCH] docs: add Core\Webhook implementation plan (8 tasks) Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-12-core-webhook-plan.md | 948 +++++++++++++++++++++ 1 file changed, 948 insertions(+) create mode 100644 docs/plans/2026-03-12-core-webhook-plan.md diff --git a/docs/plans/2026-03-12-core-webhook-plan.md b/docs/plans/2026-03-12-core-webhook-plan.md new file mode 100644 index 0000000..98c06af --- /dev/null +++ b/docs/plans/2026-03-12-core-webhook-plan.md @@ -0,0 +1,948 @@ +# Core\Webhook Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add framework-level webhook infrastructure to core/php — append-only inbound log, config-driven outbound cron triggers, replaces 4 AltumCode Docker cron containers. + +**Architecture:** `Core\Webhook` namespace in `src/Core/Webhook/`. One migration, one model, one controller, one lifecycle event, one scheduled action for cron triggers, one verifier interface. Modules subscribe to `WebhookReceived` for awareness; they query the table for processing on their own schedule. No inline processing — ever. + +**Tech Stack:** Laravel 12, Pest testing, Eloquent (ULID keys), `#[Scheduled]` attribute, lifecycle events (`ApiRoutesRegistering`), HTTP client for outbound triggers. + +--- + +### Task 1: Migration — `webhook_calls` table + +**Files:** +- Create: `database/migrations/2026_03_12_000001_create_webhook_calls_table.php` + +**Step 1: Create the migration** + +```php +ulid('id')->primary(); + $table->string('source', 64)->index(); + $table->string('event_type', 128)->nullable(); + $table->json('headers'); + $table->json('payload'); + $table->boolean('signature_valid')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['source', 'processed_at', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_calls'); + } +}; +``` + +**Step 2: Verify** + +```bash +cd /Users/snider/Code/core/php +head -5 database/migrations/2026_03_12_000001_create_webhook_calls_table.php +``` + +Expected: `declare(strict_types=1)` and `use Illuminate\Database\Migrations\Migration`. + +**Step 3: Commit** + +```bash +git add database/migrations/2026_03_12_000001_create_webhook_calls_table.php +git commit -m "feat(webhook): add webhook_calls migration — append-only inbound log" +``` + +--- + +### Task 2: Model — `WebhookCall` + +**Files:** +- Create: `src/Core/Webhook/WebhookCall.php` +- Test: `tests/Feature/WebhookCallTest.php` + +**Step 1: Write the test** + +```php + 'altum-biolinks', + 'event_type' => 'link.created', + 'headers' => ['webhook-id' => 'abc123'], + 'payload' => ['type' => 'link.created', 'data' => ['id' => 1]], + ]); + + $this->assertNotNull($call->id); + $this->assertSame('altum-biolinks', $call->source); + $this->assertSame('link.created', $call->event_type); + $this->assertIsArray($call->headers); + $this->assertIsArray($call->payload); + $this->assertNull($call->signature_valid); + $this->assertNull($call->processed_at); + } + + public function test_unprocessed_scope(): void + { + WebhookCall::create([ + 'source' => 'stripe', + 'headers' => [], + 'payload' => ['type' => 'invoice.paid'], + ]); + + WebhookCall::create([ + 'source' => 'stripe', + 'headers' => [], + 'payload' => ['type' => 'invoice.created'], + 'processed_at' => now(), + ]); + + $unprocessed = WebhookCall::unprocessed()->get(); + $this->assertCount(1, $unprocessed); + $this->assertSame('invoice.paid', $unprocessed->first()->payload['type']); + } + + public function test_for_source_scope(): void + { + WebhookCall::create(['source' => 'stripe', 'headers' => [], 'payload' => []]); + WebhookCall::create(['source' => 'altum-biolinks', 'headers' => [], 'payload' => []]); + + $this->assertCount(1, WebhookCall::forSource('stripe')->get()); + $this->assertCount(1, WebhookCall::forSource('altum-biolinks')->get()); + } + + public function test_mark_processed(): void + { + $call = WebhookCall::create([ + 'source' => 'test', + 'headers' => [], + 'payload' => [], + ]); + + $this->assertNull($call->processed_at); + + $call->markProcessed(); + + $this->assertNotNull($call->fresh()->processed_at); + } + + public function test_signature_valid_is_nullable_boolean(): void + { + $call = WebhookCall::create([ + 'source' => 'test', + 'headers' => [], + 'payload' => [], + 'signature_valid' => false, + ]); + + $this->assertFalse($call->signature_valid); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +cd /Users/snider/Code/core/php +vendor/bin/phpunit tests/Feature/WebhookCallTest.php +``` + +Expected: FAIL — class `Core\Webhook\WebhookCall` not found. + +**Step 3: Create the model** + +```php + 'array', + 'payload' => 'array', + 'signature_valid' => 'boolean', + 'processed_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function scopeUnprocessed(Builder $query): Builder + { + return $query->whereNull('processed_at'); + } + + public function scopeForSource(Builder $query, string $source): Builder + { + return $query->where('source', $source); + } + + public function markProcessed(): void + { + $this->update(['processed_at' => now()]); + } +} +``` + +**Step 4: Run tests** + +```bash +vendor/bin/phpunit tests/Feature/WebhookCallTest.php +``` + +Expected: All 5 tests pass. + +**Step 5: Commit** + +```bash +git add src/Core/Webhook/WebhookCall.php tests/Feature/WebhookCallTest.php +git commit -m "feat(webhook): add WebhookCall model — ULID, scopes, markProcessed" +``` + +--- + +### Task 3: Lifecycle event — `WebhookReceived` + +**Files:** +- Create: `src/Core/Webhook/WebhookReceived.php` + +**Step 1: Create the event** + +This is a simple value object. It carries only the source tag and call ID — never the payload. Modules subscribe to it for lightweight awareness only. + +```php +post('/webhooks/{source}', [\Core\Webhook\WebhookController::class, 'handle']); + } + + public function test_stores_webhook_call(): void + { + $response = $this->postJson('/webhooks/altum-biolinks', [ + 'type' => 'link.created', + 'data' => ['id' => 42], + ]); + + $response->assertOk(); + + $call = WebhookCall::first(); + $this->assertNotNull($call); + $this->assertSame('altum-biolinks', $call->source); + $this->assertSame(['type' => 'link.created', 'data' => ['id' => 42]], $call->payload); + $this->assertNull($call->processed_at); + } + + public function test_captures_headers(): void + { + $this->postJson('/webhooks/stripe', ['type' => 'invoice.paid'], [ + 'Webhook-Id' => 'msg_abc123', + 'Webhook-Timestamp' => '1234567890', + ]); + + $call = WebhookCall::first(); + $this->assertArrayHasKey('webhook-id', $call->headers); + } + + public function test_fires_webhook_received_event(): void + { + Event::fake([WebhookReceived::class]); + + $this->postJson('/webhooks/altum-biolinks', ['type' => 'test']); + + Event::assertDispatched(WebhookReceived::class, function ($event) { + return $event->source === 'altum-biolinks' && ! empty($event->callId); + }); + } + + public function test_extracts_event_type_from_payload(): void + { + $this->postJson('/webhooks/stripe', ['type' => 'invoice.paid']); + + $this->assertSame('invoice.paid', WebhookCall::first()->event_type); + } + + public function test_handles_empty_payload(): void + { + $response = $this->postJson('/webhooks/test', []); + + $response->assertOk(); + $this->assertCount(1, WebhookCall::all()); + } + + public function test_signature_valid_null_when_no_verifier(): void + { + $this->postJson('/webhooks/unknown-source', ['data' => 1]); + + $this->assertNull(WebhookCall::first()->signature_valid); + } + + public function test_signature_verified_when_verifier_registered(): void + { + $verifier = new class implements WebhookVerifier { + public function verify(Request $request, string $secret): bool + { + return $request->header('webhook-signature') === 'valid'; + } + }; + + $this->app->instance('webhook.verifier.test-source', $verifier); + $this->app['config']->set('webhook.secrets.test-source', 'test-secret'); + + $this->postJson('/webhooks/test-source', ['data' => 1], [ + 'Webhook-Signature' => 'valid', + ]); + + $this->assertTrue(WebhookCall::first()->signature_valid); + } + + public function test_signature_invalid_still_stores_call(): void + { + $verifier = new class implements WebhookVerifier { + public function verify(Request $request, string $secret): bool + { + return false; + } + }; + + $this->app->instance('webhook.verifier.test-source', $verifier); + $this->app['config']->set('webhook.secrets.test-source', 'test-secret'); + + $this->postJson('/webhooks/test-source', ['data' => 1]); + + $call = WebhookCall::first(); + $this->assertNotNull($call); + $this->assertFalse($call->signature_valid); + } + + public function test_source_is_sanitised(): void + { + $response = $this->postJson('/webhooks/valid-source-123', ['data' => 1]); + $response->assertOk(); + + $response = $this->postJson('/webhooks/invalid source!', ['data' => 1]); + $response->assertStatus(404); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +vendor/bin/phpunit tests/Feature/WebhookControllerTest.php +``` + +Expected: FAIL — class `Core\Webhook\WebhookController` not found. + +**Step 3: Create the controller** + +```php +bound("webhook.verifier.{$source}") + ? app("webhook.verifier.{$source}") + : null; + + if ($verifier instanceof WebhookVerifier) { + $secret = config("webhook.secrets.{$source}", ''); + $signatureValid = $verifier->verify($request, $secret); + } + + // Extract event type from common payload patterns + $payload = $request->json()->all(); + $eventType = $payload['type'] ?? $payload['event_type'] ?? $payload['event'] ?? null; + + $call = WebhookCall::create([ + 'source' => $source, + 'event_type' => is_string($eventType) ? $eventType : null, + 'headers' => $request->headers->all(), + 'payload' => $payload, + 'signature_valid' => $signatureValid, + ]); + + event(new WebhookReceived($source, $call->id)); + + return response()->json(['ok' => true]); + } +} +``` + +**Step 4: Run tests** + +```bash +vendor/bin/phpunit tests/Feature/WebhookControllerTest.php +``` + +Expected: All 9 tests pass. + +**Step 5: Commit** + +```bash +git add src/Core/Webhook/WebhookController.php tests/Feature/WebhookControllerTest.php +git commit -m "feat(webhook): add WebhookController — store, verify, fire event, return 200" +``` + +--- + +### Task 6: Config + route registration + +**Files:** +- Create: `src/Core/Webhook/config.php` +- Create: `src/Core/Webhook/Boot.php` + +**Step 1: Create the config** + +```php + [ + 'altum-biolinks' => env('ALTUM_BIOLINKS_WEBHOOK_SECRET'), + 'altum-analytics' => env('ALTUM_ANALYTICS_WEBHOOK_SECRET'), + 'altum-pusher' => env('ALTUM_PUSHER_WEBHOOK_SECRET'), + 'altum-socialproof' => env('ALTUM_SOCIALPROOF_WEBHOOK_SECRET'), + ], + + /* + |-------------------------------------------------------------------------- + | Cron Triggers + |-------------------------------------------------------------------------- + | + | Outbound HTTP triggers that replace Docker cron containers. + | The CronTrigger action hits these endpoints every minute. + | + */ + 'cron_triggers' => [ + 'altum-biolinks' => [ + 'base_url' => env('ALTUM_BIOLINKS_URL'), + 'key' => env('ALTUM_BIOLINKS_CRON_KEY'), + 'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'], + 'stagger_seconds' => 15, + 'offset_seconds' => 5, + ], + 'altum-analytics' => [ + 'base_url' => env('ALTUM_ANALYTICS_URL'), + 'key' => env('ALTUM_ANALYTICS_CRON_KEY'), + 'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'], + 'stagger_seconds' => 15, + 'offset_seconds' => 0, + ], + 'altum-pusher' => [ + 'base_url' => env('ALTUM_PUSHER_URL'), + 'key' => env('ALTUM_PUSHER_CRON_KEY'), + 'endpoints' => [ + '/cron/reset', '/cron/broadcasts', '/cron/campaigns', + '/cron/flows', '/cron/flows_notifications', '/cron/personal_notifications', + '/cron/rss_automations', '/cron/recurring_campaigns', '/cron/push_notifications', + ], + 'stagger_seconds' => 7, + 'offset_seconds' => 7, + ], + 'altum-socialproof' => [ + 'base_url' => env('ALTUM_SOCIALPROOF_URL'), + 'key' => env('ALTUM_SOCIALPROOF_CRON_KEY'), + 'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'], + 'stagger_seconds' => 15, + 'offset_seconds' => 10, + ], + ], +]; +``` + +**Step 2: Create Boot.php** + +```php + 'onApiRoutes', + ]; + + public function register(): void + { + $this->mergeConfigFrom(__DIR__.'/config.php', 'webhook'); + } + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + $event->routes(fn () => Route::post( + '/webhooks/{source}', + [WebhookController::class, 'handle'] + )->where('source', '[a-z0-9\-]+')); + } +} +``` + +**Step 3: Commit** + +```bash +git add src/Core/Webhook/config.php src/Core/Webhook/Boot.php +git commit -m "feat(webhook): add config + Boot — route registration, cron trigger config" +``` + +--- + +### Task 7: CronTrigger scheduled action + +**Files:** +- Create: `src/Core/Webhook/CronTrigger.php` +- Test: `tests/Feature/CronTriggerTest.php` + +**Step 1: Write the test** + +```php +getAttributes(Scheduled::class); + + $this->assertCount(1, $attrs); + $this->assertSame('everyMinute', $attrs[0]->newInstance()->frequency); + } + + public function test_uses_action_trait(): void + { + $this->assertTrue( + in_array(Action::class, class_uses_recursive(CronTrigger::class), true) + ); + } + + public function test_hits_configured_endpoints(): void + { + Http::fake(); + + config(['webhook.cron_triggers' => [ + 'test-product' => [ + 'base_url' => 'https://example.com', + 'key' => 'secret123', + 'endpoints' => ['/cron', '/cron/reports'], + 'stagger_seconds' => 0, + 'offset_seconds' => 0, + ], + ]]); + + CronTrigger::run(); + + Http::assertSentCount(2); + Http::assertSent(fn ($request) => str_contains($request->url(), '/cron?key=secret123')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/cron/reports?key=secret123')); + } + + public function test_skips_product_with_no_base_url(): void + { + Http::fake(); + + config(['webhook.cron_triggers' => [ + 'disabled-product' => [ + 'base_url' => null, + 'key' => 'secret', + 'endpoints' => ['/cron'], + 'stagger_seconds' => 0, + 'offset_seconds' => 0, + ], + ]]); + + CronTrigger::run(); + + Http::assertSentCount(0); + } + + public function test_logs_failures_gracefully(): void + { + Http::fake([ + '*' => Http::response('error', 500), + ]); + + config(['webhook.cron_triggers' => [ + 'failing-product' => [ + 'base_url' => 'https://broken.example.com', + 'key' => 'key', + 'endpoints' => ['/cron'], + 'stagger_seconds' => 0, + 'offset_seconds' => 0, + ], + ]]); + + // Should not throw + CronTrigger::run(); + + Http::assertSentCount(1); + } + + public function test_handles_empty_config(): void + { + Http::fake(); + config(['webhook.cron_triggers' => []]); + + CronTrigger::run(); + + Http::assertSentCount(0); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +vendor/bin/phpunit tests/Feature/CronTriggerTest.php +``` + +Expected: FAIL — class `Core\Webhook\CronTrigger` not found. + +**Step 3: Create the action** + +```php + $config) { + if (empty($config['base_url'])) { + continue; + } + + $baseUrl = rtrim($config['base_url'], '/'); + $key = $config['key'] ?? ''; + $stagger = (int) ($config['stagger_seconds'] ?? 0); + $offset = (int) ($config['offset_seconds'] ?? 0); + + if ($offset > 0) { + usleep($offset * 1_000_000); + } + + foreach ($config['endpoints'] ?? [] as $i => $endpoint) { + if ($i > 0 && $stagger > 0) { + usleep($stagger * 1_000_000); + } + + $url = $baseUrl . $endpoint . '?key=' . $key; + + try { + Http::timeout(30)->get($url); + } catch (\Throwable $e) { + logger()->warning("Cron trigger failed for {$product}{$endpoint}: {$e->getMessage()}"); + } + } + } + } +} +``` + +**Step 4: Run tests** + +```bash +vendor/bin/phpunit tests/Feature/CronTriggerTest.php +``` + +Expected: All 6 tests pass. + +**Step 5: Commit** + +```bash +git add src/Core/Webhook/CronTrigger.php tests/Feature/CronTriggerTest.php +git commit -m "feat(webhook): add CronTrigger action — replaces 4 Docker cron containers" +``` + +--- + +### Task 8: Final verification + +**Step 1: Verify file structure** + +```bash +cd /Users/snider/Code/core/php +find src/Core/Webhook/ -type f | sort +find database/migrations/ -name '*webhook*' | sort +find tests/Feature/ -name '*Webhook*' -o -name '*CronTrigger*' | sort +``` + +Expected: +``` +src/Core/Webhook/Boot.php +src/Core/Webhook/CronTrigger.php +src/Core/Webhook/WebhookCall.php +src/Core/Webhook/WebhookController.php +src/Core/Webhook/WebhookReceived.php +src/Core/Webhook/WebhookVerifier.php +src/Core/Webhook/config.php +database/migrations/2026_03_12_000001_create_webhook_calls_table.php +tests/Feature/WebhookCallTest.php +tests/Feature/WebhookControllerTest.php +tests/Feature/CronTriggerTest.php +``` + +**Step 2: Run all webhook tests** + +```bash +vendor/bin/phpunit tests/Feature/WebhookCallTest.php tests/Feature/WebhookControllerTest.php tests/Feature/CronTriggerTest.php +``` + +Expected: All tests pass (5 + 9 + 6 = 20 tests). + +**Step 3: Run lint** + +```bash +./vendor/bin/pint --test src/Core/Webhook/ tests/Feature/WebhookCallTest.php tests/Feature/WebhookControllerTest.php tests/Feature/CronTriggerTest.php +``` + +Expected: Clean. + +**Step 4: Verify strict types in all files** + +```bash +grep -rL 'declare(strict_types=1)' src/Core/Webhook/ database/migrations/*webhook* +``` + +Expected: No output (all files have strict types). + +**Step 5: Final commit if any lint fixes needed** + +```bash +git add -A src/Core/Webhook/ tests/Feature/ database/migrations/ +git status +``` + +If clean, done. If fixes needed, commit them.