Skip to content

feat: add inbound webhook support (#909)#2182

Open
bvdwalt wants to merge 1 commit intogetarcaneapp:mainfrom
bvdwalt:feat/909-inbound-webhooks
Open

feat: add inbound webhook support (#909)#2182
bvdwalt wants to merge 1 commit intogetarcaneapp:mainfrom
bvdwalt:feat/909-inbound-webhooks

Conversation

@bvdwalt
Copy link
Copy Markdown

@bvdwalt bvdwalt commented Mar 29, 2026

Checklist

  • This PR is not opened from my fork's main branch

What This PR Implements

Adds inbound webhook support, allowing external systems (CI/CD pipelines, GitHub Actions, etc.) to trigger updates in Arcane via an authenticated HTTP endpoint.

Fixes: #909

Changes Made

  • Trigger endpoint: POST /api/webhooks/trigger/<token> - public, unauthenticated; token in the URL is the sole credential
  • Token security: 32-byte random token, SHA-256 hashed at rest, arc_wh_ prefix stored for indexed lookup (never stored in plaintext)
  • Four target types: container (single container update), project (compose project redeploy), updater (environment-wide image updater), gitops (GitOps sync)
  • Enable/disable: webhooks can be disabled without deleting them; disabled webhooks return 403 when triggered
  • Event log integration: all webhook lifecycle events (create, update, delete, trigger) appear in the event log with actor attribution; trigger events include the token prefix so unexpected triggers can be traced to a specific webhook
  • Settings page: manage webhooks at Settings → Webhooks, consistent with the API keys page
  • GitOps hint: informational callout added to the GitOps sync dialog pointing users to the webhooks settings page

Testing Done

  • Development environment started: ./scripts/development/dev.sh start
  • Frontend verified at http://localhost:3000
  • Backend verified at http://localhost:3552
  • Manual testing completed:
    • Created a webhook and confirmed token is shown once only
    • Triggered a container update via curl -X POST /api/webhooks/trigger/<token> and confirmed the container was updated
    • Triggered a project update and confirmed that the project was updated.
    • Triggered an updater run and confirmed that the updater ran.
    • Triggered a GitOps sync and confirmed that it ran.
    • Confirmed invalid/unknown tokens return 404
    • Confirmed disabled webhooks return 403
    • Confirmed webhooks are scoped to their environment
    • Verified create, update, delete, and trigger events all appear in the event log with correct actor and token prefix
  • No linting errors (e.g., just lint all)
  • Backend tests pass: just test backend

AI Tool Used (if applicable)

AI Tool: Claude Code Sonnet 4.6 (Anthropic)
Assistance Level: Significant
What AI helped with: Understanding repository, Full-stack implementation - backend service, handlers, migrations, frontend components, and tests
I reviewed and edited all AI-generated output: Yes
I ran all required tests and manually verified changes: Yes

Additional Context

The trigger endpoint intentionally bypasses session auth middleware — the token in the URL is the authentication mechanism, consistent with how most webhook systems work (GitHub, GitLab, etc.). Tokens are SHA-256 hashed at rest with a short prefix stored for indexed lookup, matching the existing API key security pattern.

Disclaimer Greptiles Reviews use AI, make sure to check over its work.

To better help train Greptile on our codebase, if the comment is useful and valid Like the comment, if its not helpful or invalid Dislike

To have Greptile Re-Review the changes, mention greptileai.

Greptile Summary

This PR adds full-stack inbound webhook support, allowing external systems (CI/CD pipelines, GitHub Actions, etc.) to trigger container updates, project redeploys, environment-wide updater runs, or GitOps syncs via a single authenticated POST /api/webhooks/trigger/:token endpoint. The implementation follows the existing API-key security pattern: 32-byte tokens are SHA-256 hashed at rest with only a short prefix stored for indexed lookup, the plain-text token is surfaced once at creation, and the trigger endpoint intentionally bypasses session middleware in favour of URL-token authentication.

Key observations:

  • Token security is well-designed: json:\"-\" prevents accidental hash serialisation, UNIQUE index on token_hash, prefix narrows the DB scan before a constant-time hash comparison.
  • Environment scoping is enforced consistently across all CRUD and dispatch paths.
  • Test coverage is thorough: 27+ cases covering hashing, CRUD scoping, disabled-state enforcement, and dispatch error paths.
  • Three unexported helpers in webhook_service.go (generateWebhookToken, hashWebhookToken, parseWebhookPrefix) are missing the required Internal suffix per the repository naming convention.
  • The $effect block in webhook-form-sheet.svelte resets $state directly on dialog close, which the Svelte best-practices guide recommends moving into an event handler (handleOpenChange) instead.
  • The public trigger endpoint returns err.Error() verbatim for all error levels; 500-level responses can expose wrapped internal details (e.g. Docker daemon errors) to unauthenticated callers.

Confidence Score: 5/5

Safe to merge; all findings are P2 style/convention issues with no impact on correctness or security of the core webhook mechanism.

The feature is well-implemented with solid test coverage, correct token security (SHA-256 at rest, prefix lookup, single-reveal), proper environment scoping throughout, and consistent patterns with the existing API-key implementation. All three flagged issues are P2: a naming convention violation, a Svelte reactive-state style guideline, and an informational error-message hygiene concern. None affect runtime correctness or introduce a current security defect.

backend/internal/services/webhook_service.go (naming convention), frontend/src/lib/components/sheets/webhook-form-sheet.svelte (Svelte $effect style), backend/internal/huma/handlers/webhooks.go (error message sanitisation)

Important Files Changed

Filename Overview
backend/internal/services/webhook_service.go Core service implementing webhook CRUD, token generation/hashing, and trigger dispatch; three unexported helpers violate the 'Internal' suffix naming rule.
backend/internal/huma/handlers/webhooks.go HTTP handlers for authenticated CRUD and public trigger endpoint; raw error strings exposed to unauthenticated callers on 500 responses.
frontend/src/lib/components/sheets/webhook-form-sheet.svelte Create-webhook form sheet; uses $effect to reset $state on dialog close, violating the Svelte best-practices rule against state mutations inside effects.
backend/internal/services/webhook_service_test.go Comprehensive tests covering token hashing, CRUD scoping, enabled/disabled state, and dispatch paths; good coverage of security-sensitive paths.
backend/internal/models/webhook.go Clean GORM model for webhooks; token hash never serialised to JSON via json:"-" tag; no issues.
backend/resources/migrations/postgres/045_add_webhooks.up.sql Migration creates webhooks table with unique index on token_hash and indexes on token_prefix and environment_id; consistent with SQLite migration.
frontend/src/routes/(app)/settings/webhooks/+page.svelte Webhooks settings page; handles create flow, token reveal dialog (shown once), and delegates list management to WebhookTable; no significant issues.
frontend/src/routes/(app)/settings/webhooks/webhook-table.svelte Renders webhook list with enable/disable toggle and delete actions; uses keyed each block on webhook.id; no significant issues.
types/webhook/webhook.go Shared API types (CreateInput, Summary, Created, UpdateInput); clean separation between summary (masked token) and created (raw token); no issues.
backend/internal/bootstrap/router_bootstrap.go Correctly registers the public trigger endpoint before the auth middleware and the authenticated CRUD routes after; no issues.

Comments Outside Diff (2)

  1. backend/internal/services/webhook_service.go, line 492-517 (link)

    P2 Unexported functions missing "Internal" suffix

    Per the repository coding convention, all unexported (package-private) functions must end with the Internal suffix. Three functions introduced in this file violate that rule:

    • generateWebhookTokengenerateWebhookTokenInternal
    • hashWebhookTokenhashWebhookTokenInternal
    • parseWebhookPrefixparseWebhookPrefixInternal

    Because the test file lives in the same package (package services), the rename would propagate cleanly to webhook_service_test.go without any export changes.

    Rule Used: What: All unexported functions must have the "Inte... (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: backend/internal/services/webhook_service.go
    Line: 492-517
    
    Comment:
    **Unexported functions missing "Internal" suffix**
    
    Per the repository coding convention, all unexported (package-private) functions must end with the `Internal` suffix. Three functions introduced in this file violate that rule:
    
    - `generateWebhookToken``generateWebhookTokenInternal`
    - `hashWebhookToken``hashWebhookTokenInternal`
    - `parseWebhookPrefix``parseWebhookPrefixInternal`
    
    Because the test file lives in the same package (`package services`), the rename would propagate cleanly to `webhook_service_test.go` without any export changes.
    
    **Rule Used:** What: All unexported functions must have the "Inte... ([source](https://app.greptile.com/review/custom-context?memory=306fc233-4d2f-4ac4-bdf7-8059588e8a43))
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. frontend/src/lib/components/sheets/webhook-form-sheet.svelte, line 1510-1518 (link)

    P2 $state updated inside $effect

    The effect mutates selectedTargetType, selectedTargetId, and targetOptions directly, which is the anti-pattern described in the Svelte best-practices guide. For the reset-on-close branch, the idiomatic fix is to move the state reset into the existing handleOpenChange callback so it runs in direct response to the user action rather than as a reactive side-effect:

    function handleOpenChange(newOpenState: boolean) {
        open = newOpenState;
        if (!newOpenState) {
            selectedTargetType = 'container';
            selectedTargetId = '';
            targetOptions = [];
        }
    }

    Then the $effect only needs to call loadTargetOptions:

    $effect(() => {
        if (open) {
            loadTargetOptions(selectedTargetType);
        }
    });

    Rule Used: What: Avoid updating $state inside $effect blo... (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: frontend/src/lib/components/sheets/webhook-form-sheet.svelte
    Line: 1510-1518
    
    Comment:
    **`$state` updated inside `$effect`**
    
    The effect mutates `selectedTargetType`, `selectedTargetId`, and `targetOptions` directly, which is the anti-pattern described in the Svelte best-practices guide. For the reset-on-close branch, the idiomatic fix is to move the state reset into the existing `handleOpenChange` callback so it runs in direct response to the user action rather than as a reactive side-effect:
    
    ```js
    function handleOpenChange(newOpenState: boolean) {
        open = newOpenState;
        if (!newOpenState) {
            selectedTargetType = 'container';
            selectedTargetId = '';
            targetOptions = [];
        }
    }
    ```
    
    Then the `$effect` only needs to call `loadTargetOptions`:
    ```svelte
    $effect(() => {
        if (open) {
            loadTargetOptions(selectedTargetType);
        }
    });
    ```
    
    **Rule Used:** What: Avoid updating `$state` inside `$effect` blo... ([source](https://app.greptile.com/review/custom-context?memory=8e0bee41-b073-4a49-a01c-2c2c8782b420))
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: backend/internal/services/webhook_service.go
Line: 492-517

Comment:
**Unexported functions missing "Internal" suffix**

Per the repository coding convention, all unexported (package-private) functions must end with the `Internal` suffix. Three functions introduced in this file violate that rule:

- `generateWebhookToken``generateWebhookTokenInternal`
- `hashWebhookToken``hashWebhookTokenInternal`
- `parseWebhookPrefix``parseWebhookPrefixInternal`

Because the test file lives in the same package (`package services`), the rename would propagate cleanly to `webhook_service_test.go` without any export changes.

**Rule Used:** What: All unexported functions must have the "Inte... ([source](https://app.greptile.com/review/custom-context?memory=306fc233-4d2f-4ac4-bdf7-8059588e8a43))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: frontend/src/lib/components/sheets/webhook-form-sheet.svelte
Line: 1510-1518

Comment:
**`$state` updated inside `$effect`**

The effect mutates `selectedTargetType`, `selectedTargetId`, and `targetOptions` directly, which is the anti-pattern described in the Svelte best-practices guide. For the reset-on-close branch, the idiomatic fix is to move the state reset into the existing `handleOpenChange` callback so it runs in direct response to the user action rather than as a reactive side-effect:

```js
function handleOpenChange(newOpenState: boolean) {
    open = newOpenState;
    if (!newOpenState) {
        selectedTargetType = 'container';
        selectedTargetId = '';
        targetOptions = [];
    }
}
```

Then the `$effect` only needs to call `loadTargetOptions`:
```svelte
$effect(() => {
    if (open) {
        loadTargetOptions(selectedTargetType);
    }
});
```

**Rule Used:** What: Avoid updating `$state` inside `$effect` blo... ([source](https://app.greptile.com/review/custom-context?memory=8e0bee41-b073-4a49-a01c-2c2c8782b420))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: backend/internal/huma/handlers/webhooks.go
Line: 188-196

Comment:
**Internal error details exposed to unauthenticated callers**

`err.Error()` is returned verbatim for all non-404/403 errors, including 500-level failures. For domain errors (`ErrWebhookNotFound`, `ErrWebhookDisabled`, etc.) this is fine, but for wrapped operational errors (e.g., `"container update failed: <docker daemon message>"`) internal implementation details are visible to anyone who can guess a valid token prefix.

Consider sanitising 500-level responses to a fixed message:
```go
msg := err.Error()
if status == http.StatusInternalServerError {
    msg = "internal server error"
}
c.JSON(status, gin.H{"success": false, "error": msg})
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat: add inbound webhook support (#909)" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Context used:

  • Rule used - What: All unexported functions must have the "Inte... (source)
  • Rule used - What: Avoid updating $state inside $effect blo... (source)

@bvdwalt bvdwalt requested a review from a team March 29, 2026 07:47
Comment on lines +188 to +196
}

actor := models.User{}
if currentUser, exists := humamw.GetCurrentUserFromContext(ctx); exists && currentUser != nil {
actor = *currentUser
}

wh, rawToken, err := h.webhookService.CreateWebhook(
ctx,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Internal error details exposed to unauthenticated callers

err.Error() is returned verbatim for all non-404/403 errors, including 500-level failures. For domain errors (ErrWebhookNotFound, ErrWebhookDisabled, etc.) this is fine, but for wrapped operational errors (e.g., "container update failed: <docker daemon message>") internal implementation details are visible to anyone who can guess a valid token prefix.

Consider sanitising 500-level responses to a fixed message:

msg := err.Error()
if status == http.StatusInternalServerError {
    msg = "internal server error"
}
c.JSON(status, gin.H{"success": false, "error": msg})
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/huma/handlers/webhooks.go
Line: 188-196

Comment:
**Internal error details exposed to unauthenticated callers**

`err.Error()` is returned verbatim for all non-404/403 errors, including 500-level failures. For domain errors (`ErrWebhookNotFound`, `ErrWebhookDisabled`, etc.) this is fine, but for wrapped operational errors (e.g., `"container update failed: <docker daemon message>"`) internal implementation details are visible to anyone who can guess a valid token prefix.

Consider sanitising 500-level responses to a fixed message:
```go
msg := err.Error()
if status == http.StatusInternalServerError {
    msg = "internal server error"
}
c.JSON(status, gin.H{"success": false, "error": msg})
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

⚡️ Feature: Add container update functionality via webhook (GitHub/Docker Hub)

1 participant