From 2fad2c9d8d411074adbf3d42ee4493015da42f76 Mon Sep 17 00:00:00 2001 From: Philipp Melab Date: Thu, 6 Aug 2026 07:16:21 +0200 Subject: [PATCH 1/3] feat(webhooks): include app_instance_uuid in app instance webhook payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `app_instance.created` / `app_instance.status_changed` payloads only carried `app_instance_id`, Polydock's internal auto-increment id. Consumers never see that id: the API returns `uuid` when an instance is created, and `uuid` is the route key for the instance endpoints. So a receiver holding a uuid had no way to resolve an incoming webhook back to the instance it had provisioned. Add `app_instance_uuid` alongside the existing id, pin the identifying payload fields in a test, and document the app instance payload shape in docs/WEBHOOKS.md. Purely additive — no existing field changes, so current consumers are unaffected. Co-Authored-By: Claude Opus 5 --- ...WebhookCallForAppInstanceStatusChanged.php | 4 + docs/WEBHOOKS.md | 29 +++++ ...ookCallForAppInstanceStatusChangedTest.php | 106 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php diff --git a/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php b/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php index 715b4f2f..da6cfc3d 100644 --- a/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php +++ b/app/Listeners/CreateWebhookCallForAppInstanceStatusChanged.php @@ -39,6 +39,10 @@ public function handle(PolydockAppInstanceStatusChanged|PolydockAppInstanceCreat 'event' => $previousStatus === null ? 'app_instance.created' : 'app_instance.status_changed', 'payload' => [ 'app_instance_id' => $event->appInstance->id, + // The uuid is the instance's public identifier — it is what the + // API returns on create and what consumers hold on to, so the + // webhook has to carry it for them to resolve the instance. + 'app_instance_uuid' => $event->appInstance->uuid, 'store_id' => $event->appInstance->storeApp->store->id, 'store_name' => $event->appInstance->storeApp->store->name, 'store_app_id' => $event->appInstance->polydock_store_app_id, diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 5524adef..55d28186 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -18,6 +18,35 @@ Each delivery is an HTTP `POST` with a JSON body and the following headers: | `X-Polydock-Attempt` | The current delivery attempt number | | `X-Polydock-Signature` | `sha256=` HMAC of the raw request body | +## App instance events + +`app_instance.created` and `app_instance.status_changed` share one payload +shape. The two events differ only in `previous_status`, which is `null` for +`app_instance.created`: + +```json +{ + "app_instance_id": 42, + "app_instance_uuid": "0f1c9a3e-6d2b-4e1a-9b6f-2c4d8e7a5b31", + "store_id": 1, + "store_name": "Example store", + "store_app_id": 7, + "store_app_name": "Example app", + "previous_status": "pending-deploy", + "current_status": "deploy-completed", + "data": {}, + "timestamp": "2026-01-01T12:00:00+00:00" +} +``` + +Identify the instance by `app_instance_uuid` — it is the instance's public +identifier, the value returned by the API when the instance is created and the +key used in instance API routes. `app_instance_id` is Polydock's internal +auto-increment id and is not stable across environments. + +`data` holds the instance's provisioning data. It is redacted unless the +webhook has `include_sensitive_data` set. + ## Verifying the signature The `X-Polydock-Signature` header lets you confirm a request genuinely came diff --git a/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php b/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php new file mode 100644 index 00000000..58a0bf14 --- /dev/null +++ b/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php @@ -0,0 +1,106 @@ +create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + + PolydockStoreWebhook::factory()->active()->create([ + 'polydock_store_id' => $store->id, + 'url' => 'https://example.com/webhooks/polydock', + ]); + + $instance = new PolydockAppInstance; + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'webhook-payload-test-'.Str::random(6); + $instance->app_type = 'test-app'; + $instance->status = $status; + $instance->data = []; + // saveQuietly() skips the model's creating hook, which is what normally + // fills the uuid — set it explicitly so this test exercises the payload + // rather than the model's boot sequence. + $instance->uuid = (string) Str::uuid(); + $instance->saveQuietly(); + + return $instance; + } + + public function test_created_event_payload_carries_the_app_instance_uuid(): void + { + Queue::fake(); + + $instance = $this->makeInstance(PolydockAppInstanceStatus::NEW); + + (new CreateWebhookCallForAppInstanceStatusChanged)->handle( + new PolydockAppInstanceCreatedWithNewStatus($instance), + ); + + $call = PolydockStoreWebhookCall::query()->sole(); + + $this->assertSame('app_instance.created', $call->event); + $this->assertSame($instance->uuid, $call->payload['app_instance_uuid']); + $this->assertSame($instance->id, $call->payload['app_instance_id']); + $this->assertNull($call->payload['previous_status']); + $this->assertSame( + PolydockAppInstanceStatus::NEW->value, + $call->payload['current_status'], + ); + } + + public function test_status_changed_event_payload_carries_the_app_instance_uuid(): void + { + Queue::fake(); + + $instance = $this->makeInstance(PolydockAppInstanceStatus::PENDING_DEPLOY); + + (new CreateWebhookCallForAppInstanceStatusChanged)->handle( + new PolydockAppInstanceStatusChanged( + $instance, + PolydockAppInstanceStatus::PENDING_PRE_DEPLOY, + ), + ); + + $call = PolydockStoreWebhookCall::query()->sole(); + + $this->assertSame('app_instance.status_changed', $call->event); + $this->assertSame($instance->uuid, $call->payload['app_instance_uuid']); + $this->assertSame( + PolydockAppInstanceStatus::PENDING_PRE_DEPLOY->value, + $call->payload['previous_status'], + ); + $this->assertSame( + PolydockAppInstanceStatus::PENDING_DEPLOY->value, + $call->payload['current_status'], + ); + } +} From d3e67fbdfe52b315d3ff6bf72d8ff6f883c202ef Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 6 Aug 2026 07:50:10 +0200 Subject: [PATCH 2/3] fix(webhooks): backfill legacy null instance uuids and correct sensitive-data docs --- ...l_uuid_on_polydock_app_instances_table.php | 36 +++++++++++++++++++ docs/WEBHOOKS.md | 5 +-- ...ookCallForAppInstanceStatusChangedTest.php | 18 +++++----- 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php diff --git a/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php b/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php new file mode 100644 index 00000000..01f22c92 --- /dev/null +++ b/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php @@ -0,0 +1,36 @@ +whereNull('uuid') + ->orderBy('id') + ->chunkById(100, function ($instances): void { + foreach ($instances as $instance) { + DB::table('polydock_app_instances') + ->where('id', $instance->id) + ->update(['uuid' => Str::uuid()->toString()]); + } + }); + } + + public function down(): void + { + // Intentionally a no-op: backfilled uuids are indistinguishable from + // boot-assigned ones, and consumers may already hold them. + } +}; diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 55d28186..ebfc7a36 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -44,8 +44,9 @@ identifier, the value returned by the API when the instance is created and the key used in instance API routes. `app_instance_id` is Polydock's internal auto-increment id and is not stable across environments. -`data` holds the instance's provisioning data. It is redacted unless the -webhook has `include_sensitive_data` set. +`data` holds the instance's provisioning data. Sensitive keys are always +redacted; setting `include_sensitive_data` additionally includes the generated +app-admin username and password. ## Verifying the signature diff --git a/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php b/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php index 58a0bf14..5d7dbbd4 100644 --- a/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php +++ b/tests/Feature/Listeners/CreateWebhookCallForAppInstanceStatusChangedTest.php @@ -67,11 +67,11 @@ public function test_created_event_payload_carries_the_app_instance_uuid(): void $call = PolydockStoreWebhookCall::query()->sole(); - $this->assertSame('app_instance.created', $call->event); - $this->assertSame($instance->uuid, $call->payload['app_instance_uuid']); - $this->assertSame($instance->id, $call->payload['app_instance_id']); - $this->assertNull($call->payload['previous_status']); - $this->assertSame( + self::assertSame('app_instance.created', $call->event); + self::assertSame($instance->uuid, $call->payload['app_instance_uuid']); + self::assertSame($instance->id, $call->payload['app_instance_id']); + self::assertNull($call->payload['previous_status']); + self::assertSame( PolydockAppInstanceStatus::NEW->value, $call->payload['current_status'], ); @@ -92,13 +92,13 @@ public function test_status_changed_event_payload_carries_the_app_instance_uuid( $call = PolydockStoreWebhookCall::query()->sole(); - $this->assertSame('app_instance.status_changed', $call->event); - $this->assertSame($instance->uuid, $call->payload['app_instance_uuid']); - $this->assertSame( + self::assertSame('app_instance.status_changed', $call->event); + self::assertSame($instance->uuid, $call->payload['app_instance_uuid']); + self::assertSame( PolydockAppInstanceStatus::PENDING_PRE_DEPLOY->value, $call->payload['previous_status'], ); - $this->assertSame( + self::assertSame( PolydockAppInstanceStatus::PENDING_DEPLOY->value, $call->payload['current_status'], ); From bd04a240c7469bc9b5239456fec7aa6db30671dc Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Thu, 6 Aug 2026 08:06:05 +0200 Subject: [PATCH 3/3] fix(webhooks): close inter-chunk null-uuid window with atomic backfill and pin it with a test --- ...l_uuid_on_polydock_app_instances_table.php | 10 +++ ...BackfillUuidOnPolydockAppInstancesTest.php | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/Feature/Migrations/BackfillUuidOnPolydockAppInstancesTest.php diff --git a/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php b/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php index 01f22c92..f9dd365d 100644 --- a/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php +++ b/database/migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php @@ -16,6 +16,16 @@ */ public function up(): void { + if (in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) { + // Single atomic statement: no window between chunks where a + // concurrent status transition could still read a null uuid. + DB::statement('UPDATE polydock_app_instances SET uuid = UUID() WHERE uuid IS NULL'); + + return; + } + + // Portable fallback for drivers without UUID() (sqlite in tests, where + // there is no concurrent traffic to race against). DB::table('polydock_app_instances') ->whereNull('uuid') ->orderBy('id') diff --git a/tests/Feature/Migrations/BackfillUuidOnPolydockAppInstancesTest.php b/tests/Feature/Migrations/BackfillUuidOnPolydockAppInstancesTest.php new file mode 100644 index 00000000..671527f6 --- /dev/null +++ b/tests/Feature/Migrations/BackfillUuidOnPolydockAppInstancesTest.php @@ -0,0 +1,67 @@ +create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + + // Insert directly so no model boot hook fills the uuid, mirroring rows + // created before the uuid column existed. + $legacyId = DB::table('polydock_app_instances')->insertGetId([ + 'polydock_store_app_id' => $storeApp->id, + 'name' => 'legacy-null-uuid', + 'app_type' => 'test-app', + 'status' => PolydockAppInstanceStatus::NEW->value, + 'uuid' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $existingUuid = '0f1c9a3e-6d2b-4e1a-9b6f-2c4d8e7a5b31'; + $modernId = DB::table('polydock_app_instances')->insertGetId([ + 'polydock_store_app_id' => $storeApp->id, + 'name' => 'modern-with-uuid', + 'app_type' => 'test-app', + 'status' => PolydockAppInstanceStatus::NEW->value, + 'uuid' => $existingUuid, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_08_06_000001_backfill_uuid_on_polydock_app_instances_table.php'); + self::assertInstanceOf(Migration::class, $migration); + if (! method_exists($migration, 'up')) { + self::fail('Backfill migration does not define up()'); + } + $migration->up(); + + $legacyUuid = DB::table('polydock_app_instances')->where('id', $legacyId)->value('uuid'); + self::assertNotNull($legacyUuid); + self::assertTrue(Str::isUuid($legacyUuid)); + + // Rows that already had a uuid keep it untouched. + self::assertSame( + $existingUuid, + DB::table('polydock_app_instances')->where('id', $modernId)->value('uuid'), + ); + } +}