WebhookEngine v0.2.0 — embeddable customer portal (engine half)
WebhookEngine v0.2.0
The first minor release. Adds an embeddable customer-facing portal: SaaS operators can now hand customers a self-service <EndpointManager /> React component that runs against a narrowed /api/v1/portal/* API surface, scoped per-application via short-lived HS256 JWTs minted by the host SaaS backend. The engine never mints these tokens — it only verifies them — and the per-app signing key is generated, rotated, and revoked from the operator dashboard. No breaking API changes — the v1 route prefix and Standard Webhooks signature header names are preserved. Test count moved from 215 to 252.
Features / Fixes / Changes
Added
- Embeddable customer portal — engine half (B1 Steps 2-4): new
Application.PortalSigningKey(HS256 secret, 64-char varchar) andApplication.AllowedPortalOriginsJson(JSONB) columns; newPortalTokenAuthMiddlewarevalidates short-lived HS256 JWTs (algorithm-pinned, 15-minute lifetime cap, capability-scoped viaendpoints:read|write|testandattempts:read); newPortalCorsMiddlewaredoes per-application dynamic CORS with RFC 6454-compliant ordinal-case-insensitive origin matching; new/api/v1/portal/*route group exposes a narrowed CRUD-and-test surface that silently strips admin-only fields (transformExpression,allowedIpsJson) on writes and never returns the signing key. - Embeddable customer portal — operator dashboard (B1 Step 5): new
DashboardPortalController(/api/v1/dashboard/applications/{appId}/portal/...) with 5 cookie-authed actions (read, enable, rotate, disable, update-origins). New<PortalAccessModal />React component opened from the Applications page row actions: enable / rotate / disable controls with show-once secret reveal, chip-list editor for allowed CORS origins, copy-paste embed snippet for the host SaaS. Audit log records every mutating action withPortalSigningKeyredacted to aportalEnabledboolean — the literal secret never enters the snapshot. Cache invalidation viaPortalLookupCache.InvalidateApplication(appId)after every mutating write so rotations take effect within milliseconds rather than within the 60-second cache TTL. Application.PortalRotatedAt: new column for surfacing "last rotated at" in the dashboard portal-management UI.MessageRepository.ListAttemptsByEndpointAsync/CountAttemptsByEndpointAsync: drives the portal's per-endpoint attempt history feed; uses the existingidx_attempts_endpoint_statuscovering index, no new migration.- Bun workspaces (B1 Step 1): root
package.jsondeclares["src/dashboard", "packages/*"]so the upcoming@webhookengine/endpoint-managerpackage can land atpackages/endpoint-manager/without a second migration. Singlebun.lockat the workspace root; Dockerfile and CI workflow updated to follow.
Changed
AuditLogsControllerno longer bypasses the repository pattern: newAuditLogRepository.ListAsync(...)carries the filter chain and pagination; the controller keeps the JSON hydration since that is HTTP response-shaping, not persistence. Behavior unchanged.- Dependabot npm PRs auto-sync
bun.lockvia a newpull_request_target-triggered workflow gated ongithub.actor == 'dependabot[bot]'. Eliminates the manualbun install + commit + pushthat every minor / patch frontend bump previously required. - Documentation drift sync:
CLAUDE.mdandREADME.mdstack lines updated to matchsrc/dashboard/package.json(TypeScript 6 / Vite 8 / TanStack Query 5; previous wording said TypeScript 5.9 / Vite 7). ADR-003 (payload transformation) flipped from Proposed to Accepted with an Implementation section recording the three-phase rollout that shipped in v0.1.4. - Dependency refresh:
tailwindcssand@tailwindcss/vite4.2.4 → 4.3.0 (with the transitive@tailwindcss/nodeand@tailwindcss/oxideplatform binaries).
Security
- HS256-only algorithm allowlist on portal JWTs:
ValidAlgorithms = [HmacSha256]is enforced viaMicrosoft.IdentityModel.Tokens8.17.0;alg=noneandalg=HS384/HS512tokens are rejected withPORTAL_AUTH_INVALID_SIGNATURE. The catch-ladder absorbs algorithm-rejection exceptions without echoing the rejected algorithm name in the error response. - Per-app dynamic CORS with explicit allowed-origins enumeration (no wildcards);
PortalCorsMiddlewareechoes the validated requestOrigin(never*) and is RFC 6454-compliant case-insensitive on host comparisons. - App-scope isolation across the portal surface: every portal route reads
AppIdfrom the JWT, never from query / body / route. Cross-tenant probes return404 PORTAL_NOT_FOUND(not 403) so the response shape doesn't leak the existence of cross-tenant resources. SecretOverrideentropy floor on portal writes: the portalCreate/Updateendpoint validators require thewhsec_prefix and a 32-128 char range so a customer cannot silently downgrade their HMAC secret topassword123.- Audit redaction:
DashboardPortalControllerwrites audit-log snapshots withPortalSigningKeyreduced to a booleanportalEnabledflag; the literal secret never entersbefore_json/after_json. Verified by a load-bearing negative test that scans the column forwhsec_after a real enable call.
Quick Start
docker pull voyvodka/webhook-engine:0.2.0
docker compose -f docker/docker-compose.yml up -dThe app starts on http://localhost:5100. Dashboard login: admin@example.com / changeme. Portal access for an application is enabled from the dashboard's Applications page → row actions → Portal access.