Name webhook trigger's URL at configuration time - #5105
Merged
Conversation
3 tasks
|
Based on my review of the diff (custom webhook path feature — namespaced by project, with grandfathered bare paths), all three checks pass or are N/A. Security Review ✅
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5105 +/- ##
=======================================
+ Coverage 90.6% 90.7% +0.1%
=======================================
Files 422 422
Lines 20039 20147 +108
=======================================
+ Hits 18164 18274 +110
+ Misses 1875 1873 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
A webhook trigger can now hold a `custom_path`, so its endpoint can be named rather than only addressed by the trigger's generated id. The name has to be unique within a project rather than across the instance, so triggers carry a `project_id` denormalised from their workflow. A composite foreign key on `(workflow_id, project_id)` stops it drifting from the workflow's real project, and it is resolved inside the insert transaction so no caller has to remember it. It is nullable because it only scopes a path. A path is validated as a URL segment: lowercase letters, digits, hyphens and underscores, at most 255 characters, blank meaning unset, and never UUID-shaped, since those resolve against trigger ids and would be unreachable. The column has existed since 2022 with no validation, no uniqueness and no UI, so instances can already hold values these rules reject. The migration backfills the project, releases paths held by workflows deleted since, marks the triggers that already answered at a bare URL, dedupes what was never unique, and only then adds the indexes. Its data steps are gated on the trigger being disabled: webhook ingest ignores `deleted_at`, so a hidden workflow with an enabled trigger is still serving.
`/i/<project-id>/<custom-path>` now reaches a webhook trigger, alongside the generated `/i/<trigger-id>` URL that every trigger has always had. Resolution is tried in order and no step can match more than one row, so a request can never fail on an ambiguous path. Each step is backed by a constraint: the primary key for a trigger id, and a partial unique index for each of the two path forms. Before this the lookup resolved a path with `coalesce(custom_path, id)` through `Repo.one`, so a path set in one project could match another project's trigger id, return two rows and permanently 500 that project's live endpoint. Paths that were addressable bare at `/i/<path>` keep answering there. That set is fixed by the migration and never grows, so a path created from now on cannot claim a bare URL out from under an existing one. The ingest plug's telemetry context is narrowed to the first path segment, which is always the address. The rest is caller data: `/i/<trigger-id>/Patient/123` carries record identifiers.
A path set in the app now survives a pull, and one written in `project.yaml` now deploys, so a project can be described end to end without opening the editor. An absent key means unchanged, so deploying a spec written before this existed cannot wipe a path. `project_id` and the bare-URL marker are refused in a trigger payload: they are server-owned, and `validate_extraneous_params/2` builds its allow-list from the schema, so adding them as fields would have silently started accepting them. Provisioning releases the paths of workflows that are hidden and disabled, both before the write, so one document can drop a workflow and claim its path in the same breath, and after it project-wide, since a checked-in yaml can still name the path of a workflow deleted since. Export, the JSON endpoint and the merge all drop a path the current rules would reject, so a document written before them cannot fail the import it is deployed into. A sandbox clone keeps its parent's path verbatim and answers on its own project's URL, so promoting one never moves the parent's live URL.
The webhook wizard gains a Custom URL path field. Typing a name shows what it will be stored as, the way the project form treats a project name, and the show panel's URL updates once the trigger is saved. The field only judges a path the user actually changed, so a value written before these rules existed does not block edits to anything else. Becoming a webhook counts as a change, since a path that meant nothing on a cron row is about to become a live URL and the server will check it. A path the server refused is not the trigger's, and for a duplicate it resolves to whichever workflow legitimately owns the name, so the panel falls back to the generated URL rather than offering one that posts into another workflow. The same goes for a legacy path holding a slash, which is stored verbatim but is not addressable. Applying a YAML or AI answer that never mentions the path keeps what the trigger holds, matching the way the provisioner reads an absent key.
elias-ba
force-pushed
the
fix/4952-webhook-custom-path
branch
from
August 30, 2026 14:37
9359f11 to
12dfdb7
Compare
elias-ba
added a commit
to OpenFn/kit
that referenced
this pull request
Aug 30, 2026
OpenFn/lightning#5105 lets a webhook trigger be named, so its URL is known before it is deployed. Deploy never sent the field. The new-trigger path builds its payload from an allowlist that does not include it, and the existing-trigger path builds its fields explicitly. A path only travels when the spec mentions it. An absent key means the server keeps what it has, so deploying a spec written before this existed does not wipe a path set through the app. Writing it blank clears it, either as an empty string or as a bare key, which YAML reads as null. Cron and kafka triggers ignore it, matching the server. Both insertion points decide on the resolved trigger type rather than on what the spec wrote, because type is optional in a spec and defaults to webhook. A trigger that names a path and nothing else is a legal spec, and guarding on the spec's own type would drop the path without saying so. Pull needs no change. It fetches the yaml from Lightning, which now exports the field.
A workflow whose only change was its endpoint name hashed the same as before, so anything comparing versions decided nothing had changed. The CLI's find-changed-workflows skipped it and openfn project deploy sent nothing. Costs existing workflows nothing. A nil serialises to an empty string and the parts are concatenated, so hashing the key does not move the hash of a workflow that has no path. @openfn/project skips undefined values for the same reason, which is what keeps the two implementations equal, and its parity test against a real Lightning hash still passes. Paired with OpenFn/kit#1511, which adds the key on that side.
elias-ba
added a commit
to OpenFn/kit
that referenced
this pull request
Aug 30, 2026
…make Two gaps the review left open. The workflow version hash ignored custom_path, so find-changed-workflows saw no change when a webhook was renamed and openfn project deploy skipped it. Lightning hashes the same key now, in OpenFn/lightning#5105, and the parity test against a real Lightning hash still passes: both sides leave a workflow without a path hashing exactly as it did. And deploy diffs the server's project against the payload it is about to send, so every trigger field held on the server and never named in the spec showed up as a removal that was not going to happen. Absence means leave it alone, so the diff now hides what the payload does not carry. webhook_reply and webhook_response_config had this already.
Two problems with hashing custom_path verbatim, both found in review. ProvisioningJSON and ExportUtils drop a path the naming rules reject, so the CLI never receives one. Hashing it here left the app hash and the CLI hash disagreeing forever for exactly the grandfathered rows the migration exists to keep working, and every openfn project diff would have reported the workflow as changed with nothing to show for it. And Map.take is type blind, so a stale path on a cron or kafka row, which never served a URL and was never validated, moved the hash of a workflow nobody had touched. Both are gated now, and both directions are covered by a test that fails when the gate is removed.
This was referenced Aug 30, 2026
doc-han
approved these changes
Aug 31, 2026
elias-ba
added a commit
that referenced
this pull request
Sep 1, 2026
custom_path already has a stricter format rule and a length cap from #5105, so the null-byte and width guards this branch added to it are dead weight and the assertions were checking for a message that no longer surfaces. The invisible-codepoint fixture gains U+0890, U+0891, U+08E2 and U+110CD. Erlang 28 brought PCRE2 with newer Unicode tables, which classify those four as Format where the old build did not. For the same reason the client is no longer stricter than the server on that set, so the test that pinned the four-codepoint skew now pins zero.
josephjclark
added a commit
to OpenFn/kit
that referenced
this pull request
Sep 1, 2026
* Carry a webhook's custom path through deploy OpenFn/lightning#5105 lets a webhook trigger be named, so its URL is known before it is deployed. Deploy never sent the field. The new-trigger path builds its payload from an allowlist that does not include it, and the existing-trigger path builds its fields explicitly. A path only travels when the spec mentions it. An absent key means the server keeps what it has, so deploying a spec written before this existed does not wipe a path set through the app. Writing it blank clears it, either as an empty string or as a bare key, which YAML reads as null. Cron and kafka triggers ignore it, matching the server. Both insertion points decide on the resolved trigger type rather than on what the spec wrote, because type is optional in a spec and defaults to webhook. A trigger that names a path and nothing else is a legal spec, and guarding on the spec's own type would drop the path without saying so. Pull needs no change. It fetches the yaml from Lightning, which now exports the field. * Keep a webhook's custom path top-level when reading app state `from-app-state` destructures the trigger keys it knows and sweeps the rest into `openfn:`, so a pulled `custom_path` landed nested there rather than beside `webhook_reply`. The v1 and v2 project formats would have disagreed about where a webhook's path lives. The workflow version hash is deliberately left alone. Lightning's `canonical_form/1` does not hash `custom_path` either, and adding it on one side only would make the two hashes disagree for every workflow holding a path, which is what `find-changed-workflows` compares. Moving it needs both sides at once. * Hash the custom path, and stop deploy reporting removals it will not make Two gaps the review left open. The workflow version hash ignored custom_path, so find-changed-workflows saw no change when a webhook was renamed and openfn project deploy skipped it. Lightning hashes the same key now, in OpenFn/lightning#5105, and the parity test against a real Lightning hash still passes: both sides leave a workflow without a path hashing exactly as it did. And deploy diffs the server's project against the payload it is about to send, so every trigger field held on the server and never named in the spec showed up as a removal that was not going to happen. Absence means leave it alone, so the diff now hides what the payload does not carry. webhook_reply and webhook_response_config had this already. * Show what a deploy will clear, and hash a path only where it means something Three things from review. The diff mask hid fields the server was about to wipe. validate_by_type clears a path when a webhook becomes a cron, and clears the response config when the reply mode stops being after_completion, whether or not the payload names them. Masking those made the user confirm a deploy that retires a live URL with nothing in the diff saying so. The mask now stands down when the type or the reply mode is changing, and for a trigger being deleted, where the diff is the only place the user sees what goes with it. A path on a cron or kafka trigger is no longer hashed. It never served a URL, and Lightning stopped hashing one there in the paired PR, so the two would otherwise disagree. And the type check: the fixture's inferred literal type has no custom_path, so the new test needed the cast the rest of that file already uses. ava does not type check, and I ran it instead of tsc. * Narrow the spec trigger instead of asserting it Review point. The non-null assertion says the value is there without anything enforcing it. Nothing can reach this line with an undefined spec trigger today, since splitZip walks the union of keys and the two branches above return for spec-only and state-only, but the check costs nothing and does not depend on that staying true. * One changeset per package Deploy had two, a minor for the feature and a patch for the diff fix. The tool flattens them to the higher bump anyway, and one entry per package per release is the shape everywhere else. * Bump versions deploy 0.15.0, project 0.19.0, lexicon 2.4.3, and the dependents changesets pulls along with them. * Cut the changelog entries to a sentence CLAUDE.md says a changeset note is a single short high-level sentence with no implementation detail. Mine were three paragraphs of it. * update canonical test --------- Co-authored-by: Joe Clark <jclark@openfn.org>
7 tasks
elias-ba
added a commit
that referenced
this pull request
Sep 4, 2026
custom_path already has a stricter format rule and a length cap from #5105, so the null-byte and width guards this branch added to it are dead weight and the assertions were checking for a message that no longer surfaces. The invisible-codepoint fixture gains U+0890, U+0891, U+08E2 and U+110CD. Erlang 28 brought PCRE2 with newer Unicode tables, which classify those four as Format where the old build did not. For the same reason the client is no longer stricter than the server on that set, so the test that pinned the four-codepoint skew now pins zero.
3 tasks
elias-ba
added a commit
that referenced
this pull request
Sep 5, 2026
custom_path already has a stricter format rule and a length cap from #5105, so the null-byte and width guards this branch added to it are dead weight and the assertions were checking for a message that no longer surfaces. The invisible-codepoint fixture gains U+0890, U+0891, U+08E2 and U+110CD. Erlang 28 brought PCRE2 with newer Unicode tables, which classify those four as Format where the old build did not. For the same reason the client is no longer stricter than the server on that set, so the test that pinned the four-codepoint skew now pins zero.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR lets you name a webhook trigger's URL path, so you know the endpoint before you deploy. A trigger with a path of
facility-001in projectabc-123answers at/i/abc-123/facility-001. You can set it in the trigger panel, inproject.yaml, or through the workflows API.This came from deploying the same workflows to roughly a hundred facilities. Today that means deploying first, then opening a hundred workflows and copying a hundred generated UUIDs by hand. Now the names go in the deployment templates, and the integrators get the full list before anything is deployed.
The path namespaces on the project id rather than the project name, which is what we agreed in the requirements session. Project names are mutable and not unique, so a name in a URL is a contract that breaks on a rename. Namespacing on the project also means a path only has to be unique within its project, so a sandbox clone keeps the parent's path and answers on its own project's URL, which is what #4937 asks for.
Every URL already in the wild keeps working.
/i/<trigger-id>is tried first and never changes. Paths that were addressable bare at/i/<path>before this are marked by the migration and keep answering there, and that set can only shrink.The workflow version hash now includes the path, so a workflow whose only change is its endpoint name is no longer reported as unchanged. A workflow without a path hashes exactly as it did, and
@openfn/projecthashes the same key in OpenFn/kit#1511, so the two stay in step.Closes #4952.
Validation steps
facility-001into Custom URL path and click Finish. The webhook URL on the show panel becomes/i/<project-id>/facility-001.curl -X POST localhost:4000/i/<project-id>/facility-001 -d '{}' -H 'content-type: application/json'creates a work order. So does the old/i/<trigger-id>URL.Orders Intakein the field. It tells you it will be namedorders-intake. Type!!!and it goes red and refuses to save. Type a name another workflow in the project already uses and the save reports it against the field.custom_path. Deploy it back withopenfn deploy(needs CLI: support custom webhook paths in project.yaml kit#1511) and the path survives.Additional notes for the reviewer
triggers.custom_pathhas existed since many years now with no validation, no uniqueness and no UI, and the lookup resolved it withcoalesce(custom_path, id)throughRepo.one. Any user could point a path in their own project at another project's trigger id and permanently 500 that project's live endpoint. I reproduced it before fixing it. Production has nocustom_pathrows; self-hosted instances may.AI Usage
Please disclose whether you've used AI anywhere in this PR (it's cool, we just want to know!):
You can read more details in our Responsible AI Policy
Pre-submission checklist
/reviewwith Claude Code):owner,:admin,:editor,:viewer)