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` ` : ""}
`; } return html`
${this.name} ${this._isLoading ? "..." : "Delete"}
${this._error ? html` ` : ""}
`; } 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); }