Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ called out explicitly under **Changed** or **Removed**.

## [Unreleased]

### Added

- **Webhook trigger specs accept `auth_type`, `headers` and `secret`** (all
four auth types), and **`automations get` shows each webhook's auth
type**. See `kizen docs show automation`.

### Fixed

- **`automations update` no longer resets a webhook trigger's
authentication to Kizen API auth when the spec leaves it out**, and
`automations diff` no longer shows that reset. The trigger keeps its live
auth, so its URL no longer changes under external callers. Set
`auth_type` to change it.

## [0.3.0] — 2026-10-21

### Changed
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ src/kizen_builder/
external_refs.py # name→UUID lookups for by-name spec references
files.py # raw file bytes: the S3 upload/download legs (not JSON)
forms.py # form/survey CRUD (base_path-shared with surveys)
integration_secrets.py # integration-secret listing (read-only; values never kept)
layouts.py # record-layout CRUD (field/block arrangement)
messages.py # automation-scoped message resources (notify_* step content)
permissions.py # roles, permission groups, permissions catalog
Expand Down
3 changes: 2 additions & 1 deletion scripts/cli-tree-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4346,7 +4346,8 @@

`--check` is the session-start form: it reports whether a newer version
exists, caches the answer for a day, and always exits 0 — offline, behind a
proxy, or with no remote configured, it just says so quietly.
proxy, or with no remote configured, it just says so quietly. Under
app-builder it says the installation is managed there, and still exits 0.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
│ --check Only report whether a newer version exists; change nothing. │
Expand Down
14 changes: 14 additions & 0 deletions src/kizen_builder/api/integration_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Integration secret reads against the Kizen API."""

from __future__ import annotations

from typing import Any

from kizen_builder.api.client import KizenClient


def list_integration_secrets(client: KizenClient) -> list[dict[str, Any]]:
"""GET /api/integration-secrets — every secret's ``api_name``,
``description`` and ``obfuscated_value``. Callers keep what they need and
drop ``obfuscated_value``."""
return client.paginate("/api/integration-secrets")
7 changes: 6 additions & 1 deletion src/kizen_builder/cli/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,13 @@ def table() -> None:
trig_table = Table(title="Triggers")
trig_table.add_column("type")
trig_table.add_column("description")
trig_table.add_column("auth")
for t in result["triggers"]:
trig_table.add_row(t["trigger_type"] or "", t["description"] or "")
trig_table.add_row(
t["trigger_type"] or "",
t["description"] or "",
t.get("auth_type") or "",
)
console.print(trig_table)

step_table = Table(title="Steps")
Expand Down
40 changes: 39 additions & 1 deletion src/kizen_builder/docs/specs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ Kizen (e.g. `email_interaction`, `email_link_clicked`,
`contact_tag_added_removed`) but aren't wired here and raise `PlanError` if
you try to spec them.

### Webhook trigger auth (confirmed live 2026-10-27)

```json
"trigger_webhook": {
"name": "partner_feed",
"auth_type": "http_header",
"headers": [{"name": "Authorization", "value": "Bearer {{ integration_secret.partner_token }}"}]
}
```

- `auth_type` is `kizen_api` (the default), `http_header`, `basic_auth` or
`none`. Every type but `kizen_api` adds `?business_id=<uuid>` to
`webhook_url`.
- `http_header` takes `headers: [{name, value}]`: at least one, names 2-100
characters, no name twice. A caller must send every header, each matching
exactly. Put `{{ integration_secret.<api_name> }}` or
`{{ business_variable.<name> }}` in the value. Text around the reference is
sent as written, so `Bearer {{ integration_secret.token }}` expects
`Bearer <secret>`. A literal value is accepted, but it's stored in plain
text and `show --json` prints it, so use a reference. The CLI fills each
header's `html_value` the way the UI does.
- `basic_auth` takes `secret: {api_name}`, an integration secret whose value
is `user:pass`. Callers send `Authorization: Basic <base64 of user:pass>`.
- `none` accepts any request. Kizen API headers don't get past `http_header`
or `basic_auth`; a failed check answers 401 `Invalid webhook credentials.`
- Kizen silently drops `headers` on any type but `http_header`, and `secret`
on any type but `basic_auth`, so the spec refuses both. The old
`auth_header_name`/`auth_header_value` keys are no longer read and are
refused too.
- On `update`, a webhook trigger that leaves out `auth_type` keeps the live
automation's auth. Set `auth_type` to change it. The live trigger is matched
by trigger `id`, else by webhook `name`; a renamed trigger without its `id`
is a new trigger and gets `kizen_api` (`diff` shows it).
- `automations duplicate` keeps each webhook's auth and name. The copy's URL
uses the copy's api_name.

A `goal` step's `step_goal.triggers` are wait-until triggers with the same
`trigger_type` names and config blocks as the top level, e.g. `email_received`
to wait for a reply. Its `wait_type` is `never`, `delay` or
Expand Down Expand Up @@ -661,7 +697,9 @@ Other reserved namespaces (not real object fields, no API-queryable
catalog, never carry `data-merge-field-objectname`): `team_member.<field>`
(the notified team member's own fields), `business.<field>` (tenant
settings), `contact.<field>`, `automation_variable.<name>`,
`automation_history.<field>`, and `entity_record` pseudo-fields like
`automation_history.<field>`, `integration_secret.<api_name>` and
`business_variable.<name>` (webhook header values; a secret's label is its
description), and `entity_record` pseudo-fields like
`link_url`/`created`/`estimated_close_date`. Any OTHER namespace is treated
as a real custom object's own api_name, and its span gets
`data-merge-field-objectname` holding that object's display name.
Expand Down
4 changes: 4 additions & 0 deletions src/kizen_builder/models/spec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
TriggerWebhookConfig,
TriggerManualConfig,
AutomationTriggerDef,
WebhookAuthHeader,
WebhookAuthSecret,
)
from kizen_builder.models.spec.automations_actions_control import (
StepConditionConfig,
Expand Down Expand Up @@ -263,6 +265,8 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]:
"TriggerScheduledActivityOverdueConfig",
"TriggerNewEntityCreatedConfig",
"TriggerWebhookConfig",
"WebhookAuthHeader",
"WebhookAuthSecret",
"TriggerManualConfig",
"AutomationTriggerDef",
"StepConditionConfig",
Expand Down
59 changes: 59 additions & 0 deletions src/kizen_builder/models/spec/automations_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,35 @@ class TriggerNewEntityCreatedConfig(BaseModel):
)


class WebhookAuthHeader(BaseModel):
"""One header an `http_header` webhook caller must send."""

model_config = ConfigDict(extra="forbid")

name: str = Field(min_length=2, max_length=100)
value: str = Field(
min_length=1,
description="Use a `{{ integration_secret.<api_name> }}` reference.",
)
html_value: str | None = None


class WebhookAuthSecret(BaseModel):
"""The integration secret a `basic_auth` webhook checks against. A read
also carries `description` and `deleted`, which are ignored."""

model_config = ConfigDict(extra="ignore")

api_name: str = Field(min_length=1)


RETIRED_WEBHOOK_AUTH_KEYS = (
"auth_header_name",
"auth_header_value",
"html_auth_header_value",
)


class TriggerWebhookConfig(BaseModel):
"""Config for `trigger_type: webhook`."""

Expand All @@ -289,6 +318,36 @@ class TriggerWebhookConfig(BaseModel):
extract_raw_body_content: bool = False
extract_url_query_string: bool = True
extractors: list[dict[str, Any]] = Field(default_factory=list)
auth_type: Literal["kizen_api", "http_header", "basic_auth", "none"] | None = Field(
default=None,
description="Omitted on update: the live webhook keeps its auth.",
)
headers: list[WebhookAuthHeader] | None = None
secret: WebhookAuthSecret | None = None

# Only the combinations Kizen accepts with a 200 and then drops are
# refused here (confirmed live 2026-10-27); the rest it rejects with a
# precise 400 of its own.
@model_validator(mode="after")
def _validate_auth(self) -> Self:
if any(k in (self.model_extra or {}) for k in RETIRED_WEBHOOK_AUTH_KEYS):
raise ValueError(
"trigger_webhook.auth_header_name, auth_header_value and "
"html_auth_header_value are no longer read by Kizen. Use "
"auth_type 'http_header' with headers: "
'[{"name": ..., "value": ...}].'
)
if self.headers and self.auth_type != "http_header":
raise ValueError(
"trigger_webhook.headers only applies when auth_type is "
"'http_header'; Kizen drops it for any other auth type."
)
if self.secret is not None and self.auth_type != "basic_auth":
raise ValueError(
"trigger_webhook.secret only applies when auth_type is "
"'basic_auth'; Kizen drops it for any other auth type."
)
return self


class TriggerManualConfig(BaseModel):
Expand Down
10 changes: 6 additions & 4 deletions src/kizen_builder/tools/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,16 @@ def get_automation(api_name: str) -> dict[str, Any]:
raise LookupError(f"no automation with api_name '{api_name}'")
detail = auto_api.get_automation(client, match["id"])

triggers = [
{
triggers = []
for t in detail.get("triggers") or []:
row = {
"id": t.get("id"),
"trigger_type": t.get("trigger_type"),
"description": t.get("description"),
}
for t in (detail.get("triggers") or [])
]
if t.get("trigger_type") == "webhook":
row["auth_type"] = (t.get("trigger_webhook") or {}).get("auth_type")
triggers.append(row)

steps = []
for s in detail.get("steps") or []:
Expand Down
5 changes: 5 additions & 0 deletions src/kizen_builder/tools/merge_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
# `automation_history`, whose labels vary by containing automation
# (`merge-field-markup-captured-live.md`) — it is reserved because it has no
# queryable live metadata source, not because its labels are stable.
# `integration_secret` and `business_variable` are the webhook-header
# namespaces: the UI's 2026-10-27 capture of an `integration_secret` header
# carries no `objectname`, and its label is the secret's description.
#
# This must be an explicit allowlist, not "did a live object lookup for this
# token succeed" — `contact` is itself a real, live-queryable object api_name
Expand All @@ -66,6 +69,8 @@
"contact",
"automation_variable",
"automation_history",
"integration_secret",
"business_variable",
}
)

Expand Down
89 changes: 86 additions & 3 deletions src/kizen_builder/tools/planners/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@
AutomationStepDef,
AutomationTriggerDef,
)
from kizen_builder.models.spec.automations_triggers import trigger_config_field
from kizen_builder.models.spec.automations_triggers import (
RETIRED_WEBHOOK_AUTH_KEYS,
trigger_config_field,
)
from kizen_builder.tools import merge_fields
from kizen_builder.tools.automations import get_automation, list_automations
from kizen_builder.tools.objects import get_object
Expand Down Expand Up @@ -83,6 +86,7 @@ def __init__(self) -> None:
# rather than a single value shape.
self._objects_by_api: dict[str, Any] = {}
self._automations_by_api: dict[str, dict[str, Any]] | None = None
self._secret_descriptions: dict[str, str] | None = None

def object_uuid(self, api_name: str) -> str:
return self.object_data(api_name)["id"]
Expand Down Expand Up @@ -153,6 +157,20 @@ def automation_uuid(self, api_name: str) -> str | None:
rec = self._automations_by_api.get(api_name)
return rec["id"] if rec else None

def integration_secret_description(self, api_name: str) -> str | None:
"""The secret's description, the label the UI gives its merge field.
An unknown name returns None; the server's 400 names it."""
if self._secret_descriptions is None:
from kizen_builder.api.client import KizenClient
from kizen_builder.api.integration_secrets import list_integration_secrets

with KizenClient(load_env_config()) as client:
self._secret_descriptions = {
s["api_name"]: s.get("description") or s["api_name"]
for s in list_integration_secrets(client)
}
return self._secret_descriptions.get(api_name)

def filter_group_uuid(self, object_api_name: str, identifier: str) -> str:
"""UUID of a saved filter group on ``object_api_name`` by name or UUID."""
from kizen_builder.api.saved_views import FILTER_GROUPS_BASE
Expand Down Expand Up @@ -397,6 +415,8 @@ def _merge_server_state(
if current.get(field) is not None:
server_fields[field] = current[field]
merged = {**server_fields, **payload}
if "triggers" in merged:
merged["triggers"] = _carry_webhook_auth(merged["triggers"], current)
merged["last_revision"] = current.get("revision")
return merged

Expand Down Expand Up @@ -968,9 +988,72 @@ def _trigger_on_or_around_date(


@honours_all
def _trigger_webhook(block: dict[str, Any], _ctx: LiveContext) -> dict[str, Any]:
def _trigger_webhook(block: dict[str, Any], ctx: LiveContext) -> dict[str, Any]:
def resolve_label(namespace: str, field_path: str) -> str | None:
if namespace == "integration_secret":
return ctx.integration_secret_description(field_path)
return None

# Drop server-assigned id; pass through the rest as-is.
return _strip(block, drop={"id"})
return _webhook_auth(_strip(block, drop={"id"}), resolve_label)


_WEBHOOK_AUTH_KEYS = ("auth_type", "headers", "secret")


def _webhook_auth(
block: dict[str, Any], resolve_label: merge_fields.ResolveLabel
) -> dict[str, Any]:
"""Reduce a webhook block's auth to the shape the UI writes (confirmed
live 2026-10-27). Reads carry `headers: []`, `secret: null`, an expanded
`secret` and, on older captures, the retired `auth_header_*` keys. A
header without an `html_value` key gets the UI's rich-text mirror, whose
merge-field label for a secret is the secret's description."""
out = {k: v for k, v in block.items() if k not in RETIRED_WEBHOOK_AUTH_KEYS}
if out.get("secret") is None:
out.pop("secret", None)
else:
out["secret"] = {"api_name": out["secret"].get("api_name")}
if not out.get("headers"):
out.pop("headers", None)
else:
out["headers"] = [
h
if "html_value" in h
else {
**h,
"html_value": "<p>"
+ merge_fields.render(h["value"], resolve_label=resolve_label)
+ "</p>",
}
for h in out["headers"]
]
return out


def _carry_webhook_auth(
triggers: list[dict[str, Any]], current: dict[str, Any]
) -> list[dict[str, Any]]:
"""A spec webhook block without `auth_type` keeps the live trigger's auth:
a PUT without it resets the trigger to Kizen API auth and changes its
URL. Matched by trigger id, else by webhook name."""
live = [t for t in current.get("triggers") or [] if t.get("trigger_webhook")]
by_id = {t["id"]: t["trigger_webhook"] for t in live if t.get("id")}
by_name = {t["trigger_webhook"].get("name"): t["trigger_webhook"] for t in live}
out = []
for t in triggers:
block = t.get("trigger_webhook")
if t.get("type") != "webhook" or block is None or "auth_type" in block:
out.append(t)
continue
match = by_id.get(t.get("id")) or by_name.get(block.get("name"))
if match is None:
out.append(t)
continue
auth = _webhook_auth(match, lambda _ns, _path: None)
carried = {k: auth[k] for k in _WEBHOOK_AUTH_KEYS if k in auth}
out.append({**t, "trigger_webhook": {**block, **carried}})
return out


@honours_all
Expand Down
Loading
Loading