Repository navigation
🔨 [3550 by Claude Opus 5.5] Plugin inbound webhooks: verified endpoints that call a handler - #3579
Conversation
…lation key>/hooks/<name>`. ProPR checks the request's signature or token before any plugin code runs, then runs the plugin's handler from a queue. Everything I ran passes: 19 server-side test files including the new ones, 58 UI component tests and 5 Playwright tests. Typecheck, the zero-warning lint and the OpenAPI check are clean too. I didn't run the full repository suite or a docs site build.
Manifest. A webhook is now declared as `{ name, description, handler, verification, deliveryIdHeader?, respond? }`. `verification` is one of:
- `hmac-sha256`: a header holding an optional prefix plus the HMAC of the raw body, in hex or base64.
- `slack-v0`: Slack's signature, with the timestamp required to be within 5 minutes.
- `token-header`: a header holding an optional prefix plus the secret, compared in constant time.
Each scheme uses one of the plugin's signing-secret connections. This replaces the old placeholder `{ name, description, connection }`, so I updated the two tests that used it.
What the route does with a request:
- 404: an unknown or regenerated key, a disabled or uninstalled plugin, or a webhook name the manifest doesn't declare. Nothing is loaded.
- 401: a missing or wrong signature, a stale Slack timestamp, or no signing secret set. It is counted per installation and logged with only the plugin, webhook and reason.
- 409: a delivery id that was already received (a replay).
- 413: a body over 256 KiB.
- 429: the same rate limiter `/webhook` uses, applied before the body is read.
- 202: verified and queued. The handler then runs as a delivery of kind `webhook`, shown in Activity, with the same retry schedule as event deliveries.
Challenge responses. `respond` names another function in `handlers.ts`. It runs after verification and gets only the frozen request, never `ctx`, and it must return synchronously. Slack's `url_verification` echo works this way.
Installation key. It is stored in a new `plugin_webhook_keys` table. Regenerate replaces it in one step, so the old URLs answer 404 at once, and it is recorded in the audit trail. Uninstalling deletes the key, so a reinstall gets a new URL. Two new admin routes list the URLs and regenerate the key; I regenerated the OpenAPI spec and client types.
Plugins page. The Connect step now has a Webhook URLs section: one URL per webhook with Copy, the count of rejected requests, and Regenerate behind a confirmation. Preview screenshots (desktop, the confirmation, and phone) are attached separately.
Behaviour you might not expect:
- Request bodies are never written anywhere, so the queue lives only in the API process's memory. A webhook delivery still waiting when the API stops is marked failed on the next start, and a failed webhook delivery has no Retry button; the sending service has to send it again.
- Webhook failures don't count towards the plugin's Degraded or Paused health. While event deliveries are paused, new webhook requests get 503.
- A webhook's signing secret isn't required to enable the plugin. Until it is set, requests get 401.
Acceptance criteria
- AC1: met — `pluginWebhookReceiver.ts`, `pluginWebhookRoutes.ts`; `packages/core/test/pluginWebhooks.test.ts` ("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay; `packages/api/test/pluginWebhookRoutes.test.ts` covers 413 for oversized bodies and 429 from the rate limiter.
- AC2: met — `pluginWebhooks.test.ts` uses GitHub's documented vector and RFC 4231 test case 2 for `hmac-sha256` (hex and base64), Slack's documented request for `slack-v0`, and GitLab-style and bearer vectors for `token-header`, each with tampered cases.
- AC3: met — `pluginWebhooks.test.ts` ("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches for `ctx` gets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.
- AC4: met — `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts`: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.
- AC5: met — sentinel tests in `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts` dump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.
- AC6: met — `propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx` in `PluginConnections.tsx`; `PluginWebhookUrls.test.tsx`, `propr-ui/e2e/plugin-webhooks.pw.ts`.
- AC7: met — `docs/docs/features/plugins.md` (Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) and `docs/docs/architecture/plugin-runtime.md` (Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).
|
/ultrafix goal=8 max=10 |
|
🔄 Ultrafix loop started (goal: 8/10, max cycles: 10) First action:
|
|
✅ AI Code Review Complete requested by @propr-ultrafix Posted 1 review: |
|
CI failed: Validate unsigned linux-x64 package Please investigate and fix this CI failure.
Failure evidence
|
|
✅ Applied the requested follow-up changes in commit 3da3c8e Summary of ChangesI fixed the CI failure: the Cause: In Fix: I declared
Review digestRisk-tiered paths
Validation evidence
Diff size: 1 file changed, +1/−1 lines 🤖 Implementation Details
Undo Changes • View Task Execution 💡 ProPR Slash Commands
🤖 Generated by ProPR |
…Emit`) was failing on a test file this PR added. The packaging job runs that typecheck, so it went red too. I fixed the CI failure: the `propr-ui` typecheck (`tsc --noEmit`) was failing on a test file this PR added. The packaging job runs that typecheck, so it went red too. **Cause:** In `propr-ui/e2e/plugin-webhooks.pw.ts`, the mock state for the rejected-request count starts with `lastAt` set to a number. TypeScript therefore typed `lastAt` as `number`. The Regenerate route mock then sets it to `null` (line 43), which the type didn't allow. **Fix:** I declared `lastAt` on line 19 as `number | null`, the same way `lastReason` is already declared as `string | null`. This only changes a type, so the test behaves exactly as before. `tsc --noEmit` and ESLint on that file now pass in `propr-ui`. I didn't rerun the Playwright test or the full packaging job, since the test's behaviour didn't change. There are no new previews because nothing visible changed. PR: #3579 Comment by: @propr-dev[bot] (ID: 6899204646) Model: Claude Opus 5.5
🔍 AI Code Review — codex:gpt-6-astraOverall EvaluationThis PR adds inbound webhook verification, queued delivery, installation-key management, and UI/documentation support. It needs significant corrections before merge: replay handling, payload confidentiality, recovery ownership, and delivery identity have verified defects. ✅ Verification before dispatch — All three schemes validate credentials before handler dispatch; Slack verification also checks timestamp freshness. ✅ Administrative controls — URL management reuses durable administrator authorization, and regeneration requires the existing preflight-protected mutation contract. ✅ Focused coverage — Added tests cover signature vectors, key rotation, disabled installations, body limits, and UI interactions. This review is a static analysis of the supplied diff and context; no commands were run. The authoritative checks show 31 passed, three pending, and no failures. Merge blockersEvery finding below was introduced by this PR and must be resolved before merging. F1: 🔴 Responders run before replay rejection
F2: 🔴 Handler logs can persist request bodies
F3: 🔴 Startup recovery fails another process’s deliveries
F4: 🔴 Delivery IDs collide across webhook names
SuggestionsThese are optional follow-ups and are not sent to S1: 🟢 Describe raw bodies in OpenAPI
ScoreThe implementation has useful verification and UI coverage, but four demonstrated defects affect explicit acceptance criteria and reliable delivery. Passing checks do not cover these failure sequences. Score: 5/10 🤖 Review Details
Copy and edit to choose records from this review: 🤖 Review by ProPR |
|
✅ Applied the requested follow-up changes in commit 2031768
Summary of ChangesI fixed F1–F4. The core plugin suites pass (35 tests across F1 – a replay could run the responder. The receiver now works out the delivery id first (400 if malformed). It then reserves it in
F2 – handler output could store the body. A webhook attempt now keeps only each log line's level and time, and a fixed error: "The webhook handler failed (its error is not kept: it may contain the request)" or "…timed out". Matching the body text isn't enough, because a handler can log any part or transformation of it. The same change applies where a respond function fails: that log no longer includes the error's name, since a plugin can set it to anything. A new F3 – a starting process could fail another's deliveries. Each runner now has an owner name and a heartbeat in a new F4 – delivery ids collided across webhooks. Ids are now Things to know:
Not fixed, outside these findings: the audit record that Review digestRisk-tiered paths
Validation evidence
Diff size: 15 files changed, +391/−80 lines 🤖 Implementation Details
💡 ProPR Slash Commands
🤖 Generated by ProPR |
… `pluginStore`, `pluginContributions` and `pluginWebhooks`), and so do the three API route test files. `pluginRoutes` passed and then hung at exit until the timeout killed it, which is a known pattern for API tests in this container. Typecheck for core and api, the zero-warning lint on the changed source files, the OpenAPI check and the `PluginActivity` UI test are clean. I fixed F1–F4. The core plugin suites pass (35 tests across `pluginStore`, `pluginContributions` and `pluginWebhooks`), and so do the three API route test files. `pluginRoutes` passed and then hung at exit until the timeout killed it, which is a known pattern for API tests in this container. Typecheck for core and api, the zero-warning lint on the changed source files, the OpenAPI check and the `PluginActivity` UI test are clean. **F1 – a replay could run the responder.** The receiver now works out the delivery id first (400 if malformed). It then reserves it in `plugin_deliveries` before any plugin code runs, so a replay gets 409 without loading the module or calling `respond`. The runner gained `reserve`, `start`, `answered` and `release`, and `accept` is now just reserve then start. - **Challenge answered:** if the id came from the sender, the record is marked succeeded under the respond function's name, so a replayed challenge also gets 409. A ProPR-assigned id can't be sent again, so its record is deleted. - **Refused before delivery:** a respond failure (500), a paused plugin (503) or an error releases the reservation, so the sender's retry is accepted rather than treated as a replay. - **New test:** a responder combined with `X-Delivery-Id`, checking that it ran once on a replayed ordinary event and on a replayed challenge, and that a paused-then-retried request is delivered. **F2 – handler output could store the body.** A webhook attempt now keeps only each log line's level and time, and a fixed error: "The webhook handler failed (its error is not kept: it may contain the request)" or "…timed out". Matching the body text isn't enough, because a handler can log any part or transformation of it. The same change applies where a respond function fails: that log no longer includes the error's name, since a plugin can set it to anything. A new `leakyWebhook` fixture logs the body as a message and in fields and throws it, and the sentinel test checks that no table or log line contains `customer-private-message-123`. **F3 – a starting process could fail another's deliveries.** Each runner now has an owner name and a heartbeat in a new `plugin_webhook_runners` table, renewed every 30 s. Each delivery records its owner in a new `webhook_owner` column. Recovery runs at startup and with every heartbeat, as a single UPDATE, and fails only deliveries with no owner or whose owner's heartbeat is more than 2 minutes old. A new test runs two runners: starting B fails nothing of A, A's retry still succeeds, and a delivery owned by a runner with a stale heartbeat is failed. **F4 – delivery ids collided across webhooks.** Ids are now `whk-<webhook>:<sender id>`, or `whk-<webhook>/<uuid>` when ProPR assigns one. Webhook names can't contain `:` or `/`, so the name always ends where the separator is. The new test sends `a` with `b-c` and `a-b` with `c`, and both are delivered. Things to know: - I added the new table and column to this PR's existing migration, `20261116000000_plugin_webhook_keys.js`, rather than a new file, because tests list migrations by name. Each step is guarded, so it is safe to re-run. - Webhook Activity entries no longer show the handler's error or log text. That costs debugging detail, and it's the only way to meet AC5 for arbitrary request content. - A process whose event loop stalls for more than 2 minutes would be taken for stopped, and its deliveries failed. That's inherent in using a heartbeat. - I updated the docs (`plugins.md`, `plugin-runtime.md`), the `deliveryId` description in the OpenAPI spec and generated client types, and the existing tests that expected the old id format and error text. The fixture manifest has four new webhooks, so the route test's list of webhooks changed too. - There are no visible UI changes, so I made no previews. Not fixed, outside these findings: the audit record that `ctx.propr` calls write to `plugin_audit` stores the call's details. A webhook handler that puts request content into such a call (for example a comment body) would have it stored there. PR: #3579 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5
|
✅ AI Code Review Complete requested by @propr-ultrafix Posted 1 review: |
🔍 AI Code Review — codex:gpt-6-astraOverall EvaluationThis PR adds inbound webhook verification, delivery tracking, challenge responses, and administration UI. It needs changes before merge: asynchronous responder failures, concurrent capacity admission, and installation lifecycle races remain. ✅ Verification before execution — Signature verification and delivery-ID reservation precede responder loading, including replayed challenges. ✅ Restricted diagnostics — Webhook delivery records discard handler error text and log contents that could contain request data. ✅ End-to-end coverage — The diff includes verification vectors, route tests, lifecycle tests, and UI coverage. The supplied current-head status shows 32 passed, 2 pending, and no failed checks. This review is based on static tracing; no commands were run. Merge blockersEvery finding below was introduced by this PR and must be resolved before merging. F5: 🔴 Rejected responder promises remain unhandled
F6: 🔴 Concurrent requests bypass queue capacity
F7: 🔴 Old requests can recreate deliveries after uninstall
SuggestionsThese are optional follow-ups and are not sent to S2: 🟢 Clarify responder isolation claimsThe new documentation says responders cannot send HTTP requests because they receive no ScoreThe implementation covers the intended feature broadly, but three localized runtime and lifecycle defects require correction. Passing checks do not cover these failure sequences. Score: 6/10 🤖 Review Details
Copy and edit to choose records from this review: 🤖 Review by ProPR |
|
✅ Applied the requested follow-up changes in commit c8af153
Summary of ChangesI fixed F5, F6 and F7. Three new regression tests pass and fail when I put the old runner and receiver code back. All 13 tests in F5: async responder rejections. The check that refuses a promise-shaped answer ( F6: queue capacity under concurrency. F7: stale requests after uninstall. I looked at the other places the runner acts on a reserved record ( There are no visual changes, so I made no previews. Review digestRisk-tiered paths
Validation evidence
Diff size: 3 files changed, +132/−19 lines 🤖 Implementation Details
💡 ProPR Slash Commands
🤖 Generated by ProPR |
…fail when I put the old runner and receiver code back. All 13 tests in `pluginWebhooks.test.ts` pass, and so do `pluginWebhookRoutes.test.ts` and `pluginStore.test.ts`. Core typecheck (including the changed test file) and the zero-warning lint on the two changed source files are clean. I fixed F5, F6 and F7. Three new regression tests pass and fail when I put the old runner and receiver code back. All 13 tests in `pluginWebhooks.test.ts` pass, and so do `pluginWebhookRoutes.test.ts` and `pluginStore.test.ts`. Core typecheck (including the changed test file) and the zero-warning lint on the two changed source files are clean. **F5: async responder rejections.** The check that refuses a promise-shaped answer (`checkedResponse` in `pluginWebhookReceiver.ts`) now attaches a no-op rejection handler to the promise before throwing. The request still gets the same 500 and its reservation is still given back. The regression uses an `async` responder that throws at once and one that throws after an await. It records unhandled rejections and also checks that nothing was queued. On the old code Node reported the after-await rejection as unhandled. **F6: queue capacity under concurrency.** `reserve` in `pluginWebhookRunner.ts` now takes a capacity slot before its first await. That slot counts towards the limit along with held and reserved deliveries. It turns into a reservation at the same moment the record is added, and is given back on every other path: duplicate, stale installation, shutdown during registration, or an error. The regression sends a burst of 5 concurrent reserves at `maxPending: 2` and exactly 2 get in. It also checks that a duplicate or a stale request leaves its slot free, and that a released slot can be used once. **F7: stale requests after uninstall.** `reserve` now checks the installation and inserts the record in the same transaction. If the installation is gone, has a different `installed_at`, or is disabled, it refuses with a new reason, `gone`, and records nothing; the receiver answers that with 404, as it would for a dead URL. The database is a single SQLite connection, so this transaction runs strictly before or after uninstall's transaction: either nothing is inserted, or uninstall deletes the row. The regression holds a verified request after its key was resolved, then uninstalls, reinstalls and re-enables the plugin before letting it continue. The held request gets 404 and leaves no record, and the same delivery id sent to the new URL gets 202 and runs. I looked at the other places the runner acts on a reserved record (`start`, `answered`, `release` and the attempt loop). After an uninstall they find no record and do nothing, so the same defect isn't there and they needed no change. There are no visual changes, so I made no previews. PR: #3579 Comment by: @propr-ultrafix (ID: 0) Model: Claude Opus 5.5
|
⏳ Ultrafix is waiting for CI before the next These blocking checks on
Checks matching this repository's non-blocking checks never gate Ultrafix. The loop continues automatically once the blocking checks pass; if they have not settled within 2 hours, Ultrafix stops. |
ProPR 🔨 Waiting for CI · Ultrafix cycle 3 of 10 · last review 6/10 · checks: 34 passed, 9 pending · updated 19:50 UTC
AI Implementation Summary
Closes #3550
Branch:
3550/claude-opus-5-5-plugin-inbound-webhooks-v-20261115-1755-x67Commits: ✅ Changes committed (c2b0f6c)
AI Processing Completed
Execution Details:
Summary:
External services can now call a plugin at
POST /api/plugins/<installation key>/hooks/<name>. ProPR checks the request's signature or token before any plugin code runs, then runs the plugin's handler from a queue. Everything I ran passes: 19 server-side test files including the new ones, 58 UI component tests and 5 Playwright tests. Typecheck, the zero-warning lint and the OpenAPI check are clean too. I didn't run the full repository suite or a docs site build.Manifest. A webhook is now declared as
{ name, description, handler, verification, deliveryIdHeader?, respond? }.verificationis one of:hmac-sha256: a header holding an optional prefix plus the HMAC of the raw body, in hex or base64.slack-v0: Slack's signature, with the timestamp required to be within 5 minutes.token-header: a header holding an optional prefix plus the secret, compared in constant time.Each scheme uses one of the plugin's signing-secret connections. This replaces the old placeholder
{ name, description, connection }, so I updated the two tests that used it.What the route does with a request:
/webhookuses, applied before the body is read.webhook, shown in Activity, with the same retry schedule as event deliveries.Challenge responses.
respondnames another function inhandlers.ts. It runs after verification and gets only the frozen request, neverctx, and it must return synchronously. Slack'surl_verificationecho works this way.Installation key. It is stored in a new
plugin_webhook_keystable. Regenerate replaces it in one step, so the old URLs answer 404 at once, and it is recorded in the audit trail. Uninstalling deletes the key, so a reinstall gets a new URL. Two new admin routes list the URLs and regenerate the key; I regenerated the OpenAPI spec and client types.Plugins page. The Connect step now has a Webhook URLs section: one URL per webhook with Copy, the count of rejected requests, and Regenerate behind a confirmation. Preview screenshots (desktop, the confirmation, and phone) are attached separately.
Behaviour you might not expect:
Acceptance criteria
pluginWebhookReceiver.ts,pluginWebhookRoutes.ts;packages/core/test/pluginWebhooks.test.ts("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay;packages/api/test/pluginWebhookRoutes.test.tscovers 413 for oversized bodies and 429 from the rate limiter.pluginWebhooks.test.tsuses GitHub's documented vector and RFC 4231 test case 2 forhmac-sha256(hex and base64), Slack's documented request forslack-v0, and GitLab-style and bearer vectors fortoken-header, each with tampered cases.pluginWebhooks.test.ts("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches forctxgets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.pluginWebhooks.test.tsandpluginWebhookRoutes.test.ts: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.pluginWebhooks.test.tsandpluginWebhookRoutes.test.tsdump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsxinPluginConnections.tsx;PluginWebhookUrls.test.tsx,propr-ui/e2e/plugin-webhooks.pw.ts.docs/docs/features/plugins.md(Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) anddocs/docs/architecture/plugin-runtime.md(Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).Review digest
Acceptance criteria (7 of 7 met, as stated by the agent)
Valid signature runs the handler once; bad signature, stale timestamp, replayed delivery id and oversized body are rejected without running plugin code; the rate limiter applies.—`pluginWebhookReceiver.ts`, `pluginWebhookRoutes.ts`; `packages/core/test/pluginWebhooks.test.ts` ("a verified request runs its handler once; … run no plugin code") checks the handler runs once and no plugin module loads for a bad signature, stale timestamp or replay; `packages/api/test/pluginWebho…Each verification scheme has a test with a documented test vector.—`pluginWebhooks.test.ts` uses GitHub's documented vector and RFC 4231 test case 2 for `hmac-sha256` (hex and base64), Slack's documented request for `slack-v0`, and GitLab-style and bearer vectors for `token-header`, each with tampered cases.The synchronous challenge response works and cannot call `ctx`.—`pluginWebhooks.test.ts` ("the synchronous challenge response …"): the challenge is echoed with no handler run, and a respond function that reaches for `ctx` gets one frozen argument, fails with 500, and changes nothing. The route test also checks the echo over HTTP.A disabled plugin's URL answers 404; regenerating the key invalidates the old URL.—`pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts`: disabled, uninstalled and reinstalled plugins answer 404, and after Regenerate the old URL answers 404 while the new one works.Request bodies and signature headers are never stored or logged (sentinel test).—sentinel tests in `pluginWebhooks.test.ts` and `pluginWebhookRoutes.test.ts` dump every table and capture every log and console line. I also added a deliberate body log, and the test caught it.The Plugins page shows the URL per webhook with copy and Regenerate.—`propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx` in `PluginConnections.tsx`; `PluginWebhookUrls.test.tsx`, `propr-ui/e2e/plugin-webhooks.pw.ts`.Docs: webhook verification schemes and the URL in `docs/docs/features/plugins.md` and `docs/docs/architecture/plugin-runtime.md`.—`docs/docs/features/plugins.md` (Inbound webhooks: the URL, verification schemes, delivery, challenge responses, routes) and `docs/docs/architecture/plugin-runtime.md` (Inbound webhooks: key, request path, schemes table, respond function, delivery runner, what is stored).Risk-tiered paths
packages/core/src/db/migrations/20261116000000_plugin_webhook_keys.js(added, +30/−0): database migrationpackages/api/openapi/directRoutes.ts(+2/−0)packages/api/openapi/registeredRoutes.ts(+1/−1)packages/api/openapi/routeDocsPlugins.ts(+28/−1)packages/api/openapi/schemasPlugins.ts(+18/−7)packages/api/pluginRouteRegistry.ts(+5/−2)packages/api/routes/pluginWebhookRoutes.ts(added, +71/−0)packages/api/server.ts(+12/−1)packages/client/src/generated/apiTypes.ts(+38/−10)packages/core/src/index.ts(+3/−0)packages/core/src/plugins/pluginStore.ts(+2/−0)packages/core/src/plugins/pluginWebhookReceiver.ts(added, +125/−0)packages/core/src/plugins/pluginWebhookRunner.ts(added, +167/−0)packages/core/src/plugins/pluginWebhooks.ts(added, +213/−0)packages/shared/src/pluginDeliveries.ts(+10/−5)packages/shared/src/pluginInstallations.ts(+1/−1)packages/shared/src/pluginSdk.ts(+32/−1)packages/shared/src/plugins.ts(+48/−5)propr-ui/src/api/pluginsApi.ts(+7/−0)propr-ui/src/pages/PluginsPage/PluginActivity.tsx(+8/−6)propr-ui/src/pages/PluginsPage/PluginConnections.tsx(+3/−1)propr-ui/src/pages/PluginsPage/PluginWebhookUrls.tsx(added, +95/−0)propr-ui/src/pages/PluginsPage/usePluginWebhooks.ts(added, +33/−0)docs/docs/architecture/plugin-runtime.md(+128/−2)docs/docs/features/plugins.md(+162/−5)docs/docs/operations/configuration-reference.md(+1/−1)docs/static/openapi/propr-api.yaml(+265/−6)packages/api/test/pluginActivityRoutes.test.ts(+4/−2)packages/api/test/pluginRoutes.test.ts(+4/−2)packages/api/test/pluginWebhookRoutes.test.ts(added, +183/−0)packages/core/test/fixtures/plugins/fixture-webhooks/handlers.ts(added, +45/−0)packages/core/test/fixtures/plugins/fixture-webhooks/manifest.json(added, +34/−0)packages/core/test/fixtures/plugins/registry.json(+2/−1)packages/core/test/helpers/pluginHandlerFixture.ts(+2/−1)packages/core/test/pluginContributions.test.ts(+2/−1)packages/core/test/pluginStore.test.ts(+3/−2)packages/core/test/pluginWebhooks.test.ts(added, +257/−0)packages/shared/test/plugins.test.ts(+20/−2)propr-ui/e2e/plugin-webhooks.pw.ts(added, +86/−0)propr-ui/src/pages/PluginsPage/PluginActivity.test.tsx(+11/−0)propr-ui/src/pages/PluginsPage/PluginWebhookUrls.test.tsx(added, +113/−0)Validation evidence
Diff size: 41 files changed, +2,274/−66 lines
Detailed Logs:
9ec4bcf7-6210-40a3-b9af-c0c5552352deLog files stored at:
/usr/src/app/logs/executions/issue-3550-2026-11-15T18-26-13-060Z-conversation.json/usr/src/app/logs/executions/issue-3550-2026-11-15T18-26-13-060Z-output.txtLatest Conversation Messages
This PR was created automatically by ProPR after processing issue #3550.
💡 Need changes?
Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!
Visual preview
Webhook URLs in the Connect step (desktop)
Below the signing secret, one URL per webhook with its verification scheme and a Copy button (the second just copied), the count of rejected unverified requests, and Regenerate.
Full-size original
Webhook URLs after Regenerate (phone)
The new URLs at 390 px wide, with full-width Copy buttons and no horizontal overflow; the rejected count started over with the new key.
Full-size original
Regenerate confirmation (desktop)
Regenerate asks for confirmation first: every URL stops working at once.
Full-size original