import { css, html } from "lit";
import { BaseElement } from "../../core/base-element.ts";
import { CellHandle } from "@commontools/runtime-client";
// Design spec: docs/specs/webhook-ingress/README.md
export interface WebhookConfig {
url: string;
secret: string;
}
/**
* CTWebhook - Webhook integration component
*
* Creates and manages a webhook endpoint. The component handles all API
* interaction internally — patterns never call /api/webhooks directly.
* Follows the same model as ct-google-oauth: the pattern passes a cell
* handle, the component manages the lifecycle.
*
* The component creates the confidential config cell internally so the
* pattern never needs to manage CFC labels for secrets.
*
* @element ct-webhook
*
* @attr {string} name - Human-readable label for the webhook
* @attr {CellHandle} inbox - Stream that receives webhook payloads (pass via $inbox)
* @attr {CellHandle} config - Cell for URL+secret storage (pass via $config)
*
* @example
*
*/
export class CTWebhook extends BaseElement {
static override properties = {
name: { type: String },
inbox: { type: Object, attribute: false },
config: { type: Object, attribute: false },
_isLoading: { type: Boolean, state: true },
_error: { type: String, state: true },
};
declare name: string;
declare inbox: CellHandle;
declare config: CellHandle;
declare _isLoading: boolean;
declare _error: string;
private _configUnsub?: () => void;
constructor() {
super();
this.name = "";
this._isLoading = false;
this._error = "";
}
override updated(changedProperties: Map) {
super.updated(changedProperties);
if (changedProperties.has("config")) {
this._subscribeToConfig();
}
}
private _subscribeToConfig() {
this._configUnsub?.();
this._configUnsub = undefined;
if (this.config?.subscribe) {
this._configUnsub = this.config.subscribe(() => {
this.requestUpdate();
});
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this._configUnsub?.();
this._configUnsub = undefined;
}
private _getConfig(): WebhookConfig | null {
try {
return this.config?.get() ?? null;
} catch {
return null;
}
}
private async _handleCreate() {
if (this._isLoading) return;
if (!this.inbox || !this.config || !this.name) {
this._error = "Missing required properties: name, inbox, config";
return;
}
this._isLoading = true;
this._error = "";
try {
const inboxJson = this.inbox?.toJSON?.() as Record;
if (!inboxJson?.["/"]) {
throw new Error("inbox is not a valid cell link");
}
const cellLink = JSON.stringify(inboxJson);
const configJson = this.config?.toJSON?.() as Record;
if (!configJson?.["/"]) {
throw new Error("config is not a valid cell link");
}
const confidentialCellLink = JSON.stringify(configJson);
const response = await fetch("/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: this.name,
cellLink,
confidentialCellLink,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${response.status}`);
}
await response.json();
this._isLoading = false;
this.requestUpdate();
} catch (error) {
this._error = error instanceof Error
? error.message
: "Failed to create webhook";
this._isLoading = false;
}
}
private async _handleDelete() {
if (this._isLoading) return;
const configData = this._getConfig();
if (!configData?.url) return;
// Extract webhook ID from the config URL (format: .../api/webhooks/{id})
let webhookId: string | undefined;
try {
webhookId = new URL(configData.url).pathname.split("/").pop();
} catch {
webhookId = configData.url.split("/").pop();
}
if (!webhookId) return;
this._isLoading = true;
this._error = "";
try {
// Extract space DID from inbox cell link for ownership verification
const inboxLink = this.inbox?.toJSON?.() as any;
const linkData = inboxLink?.["/"]?.["link@1"] ??
inboxLink?.["/"]?.["link-v0.1"];
const space = linkData?.space ?? "";
const params = new URLSearchParams({ space });
const response = await fetch(
`/api/webhooks/${webhookId}?${params}`,
{ method: "DELETE" },
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${response.status}`);
}
// Clear the config cell. The inbox stream reference remains on the
// pattern side but will simply stop receiving new events.
await this.config.set(null);
this._isLoading = false;
} catch (error) {
this._error = error instanceof Error
? error.message
: "Failed to delete webhook";
this._isLoading = false;
}
}
override render() {
const configData = this._getConfig();
const hasWebhook = configData?.url && configData?.secret;
if (!hasWebhook) {
return html`
${this._isLoading ? "Creating..." : `Create Webhook`}
${this._error
? html`
${this._error}
`
: ""}
`;
}
return html`
${this._error
? html`
${this._error}
`
: ""}
`;
}
static override styles = [
BaseElement.baseStyles,
css`
:host {
display: block;
}
.webhook-setup {
display: flex;
flex-direction: column;
gap: var(--spacing-2, 0.5rem);
}
.webhook-card {
display: flex;
flex-direction: column;
gap: var(--spacing-3, 0.75rem);
padding: var(--spacing-4, 1rem);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 0.375rem);
background: var(--color-bg-subtle, #f9fafb);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.name {
font-weight: 600;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-primary, #111827);
}
.error {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-error, #dc2626);
padding: var(--spacing-2, 0.5rem);
background: var(--color-error-bg, #fef2f2);
border-radius: var(--radius-sm, 0.25rem);
}
`,
];
}
if (!globalThis.customElements.get("ct-webhook")) {
globalThis.customElements.define("ct-webhook", CTWebhook);
}