Skip to content

Latest commit

 

History

History
216 lines (159 loc) · 10.8 KB

webhooks.md

File metadata and controls

216 lines (159 loc) · 10.8 KB

Webhooks

Related: Concepts · Publish Flow · Open API

Webhooks deliver HTTP POST notifications to external URLs when content or translation events occur in a space. Each webhook is scoped to a space, can subscribe to multiple events, and optionally signs payloads with HMAC-SHA256.


Firestore Structure

spaces/{spaceId}/webhooks/{webhookId}          — webhook config
spaces/{spaceId}/webhooks/{webhookId}/logs/{logId}  — execution history (not capped or pruned; one doc per dispatch)

Access control: SPACE_MANAGEMENT (or admin) required to read/write webhook configs and to read logs. Logs are client read-only; they are written by Functions via the Admin SDK.


Data Model

interface WebHook {
  name: string;
  url: string;
  enabled: boolean;
  events: WebHookEvent[];
  headers?: Record<string, string>;  // custom request headers
  secret?: string;                   // HMAC-SHA256 signing key
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

Events

Event Value Triggered by
CONTENT_PUBLISHED content.published publish callable
CONTENT_UNPUBLISHED content.unpublished unpublish callable
CONTENT_CHANGED content.changed onContentUpdate / onContentDelete (Firestore onDocumentUpdated/onDocumentDeleted triggers)
TRANSLATION_PUBLISHED translation.published publish callable
TRANSLATION_CHANGED translation.changed generateTranslationsDraft()

Payload

interface WebHookPayload {
  event: WebHookEvent;
  spaceId: string;
  timestamp: string;           // ISO 8601
  data: ContentWebHookPayloadData | TranslationWebHookPayloadData;
  signature?: string;          // internal only — the signature is delivered in the X-Webhook-Signature header, not the body
}

Content events send data: { id, fullSlug } (ContentWebHookPayloadData) — the content's ID and full slug. Translation events send an empty data object.


HTTP Request

Every webhook dispatch sends:

POST <webhook.url>
Content-Type: application/json
User-Agent: Localess-WebHook/1.0
X-Webhook-Event: <event>
X-Webhook-Delivery: <uuid>          ← unique per dispatch; same value as the log's deliveryId
X-Webhook-Signature: sha256=<hex>   ← only if secret is set
<custom headers from webhook.headers, minus reserved ones — see below>

Body: JSON-serialised WebHookPayload (without `signature`)
  • Timeout: 30 seconds
  • Retries: none
  • Redirects: not followed. A 3xx is logged as an http failure with its status code.
  • Response body: at most 4 KB is read, which is all that gets logged.
  • All enabled webhooks for an event fire concurrently via Promise.allSettled() (one failure does not block others). Each webhook signs its own copy of the payload, so no webhook ever sees another's signature.

Destination restrictions (SSRF protection)

Delivery runs inside the project with the Functions service account, and the URL and headers are chosen by a space manager. So functions/src/utils/webhook-request.ts limits where a webhook can go. Without these limits, a webhook, or a redirect from one, could reach the GCP metadata server (169.254.169.254, which issues the service account's OAuth token) and the reply would show up in the webhook log.

  • URL (checkWebhookUrl): https: only, on the default port, with no credentials in the URL. localhost, *.localhost, *.internal, metadata.google.internal and private IP literals are refused. The WHATWG URL parser normalises shorthand forms such as 0x7f.1 and 2130706433 before the check.
  • Resolved address (isBlockedAddress, applied in the socket's DNS lookup): the connection is refused if the host resolves to any loopback, private, link-local, CGNAT, multicast, reserved or documentation range, IPv4 or IPv6, including IPv4-mapped IPv6. The check runs on the exact address being connected to, so DNS rebinding can't get around it.
  • Headers (sanitizeWebhookHeaders): custom headers can't set Host, Metadata-Flavor, Content-Length, Transfer-Encoding, Connection, Content-Type, User-Agent, or any X-Webhook-* header. Those are dropped.
  • Emulator (FUNCTIONS_EMULATOR=true): http: and local or private targets are allowed, so a local receiver can be used.

A refused delivery sends nothing. It is logged as a network failure with errorMessage "Webhook URL is not allowed: …".


HMAC Signing

When a webhook has a secret, the payload is signed before dispatch:

body      = JSON.stringify(payload)          // serialised once, before signing
signature = HMAC-SHA256(secret, body)
header:   X-Webhook-Signature: sha256=<hex_digest>

The signature is delivered only in the X-Webhook-Signature header. Receivers must read it from the header and verify it by computing the HMAC over the raw request body exactly as received (do not re-serialise parsed JSON, and do not rely on any signature field in the body).


Execution Logging

Every dispatch — success or failure — writes a log entry to the logs subcollection. WebHookLog is a discriminated union on status:

enum WebHookStatus {
  SUCCESS = 'success',
  FAILURE = 'failure',
}

enum WebHookErrorType {
  TIMEOUT = 'timeout',
  NETWORK = 'network',
  HTTP = 'http',
}

interface WebHookLogBase {
  event: WebHookEvent;
  url: string;
  requestSize: number;
  data: ContentWebHookPayloadData | TranslationWebHookPayloadData;
  deliveryId: string;
  duration: number;       // milliseconds
  createdAt: Timestamp;
}

interface WebHookLogSuccess extends WebHookLogBase {
  status: WebHookStatus.SUCCESS;
  statusCode: number;
  statusText: string;
  responseBody?: string;
  responseBodyTruncated?: boolean;
}

interface WebHookLogFailure extends WebHookLogBase {
  status: WebHookStatus.FAILURE;
  errorType: WebHookErrorType;       // timeout | network | http
  statusCode?: number;
  statusText?: string;
  responseBody?: string;
  responseBodyTruncated?: boolean;
  errorMessage?: string;
}

type WebHookLog = WebHookLogSuccess | WebHookLogFailure;

Logs are not capped on write — every dispatch adds a document and nothing prunes them (they are only removed with the webhook, see Cleanup). Limits apply only on read: the backend findWebHookLogs() query returns the latest 100 by createdAt descending, and the frontend WebHookService.findLogs() takes an optional max. The WebhookDetailComponent shows the log history with pagination and filtering.


Trigger Call Sites

File Event
functions/src/contents.ts — publish callable CONTENT_PUBLISHED
functions/src/contents.ts — unpublish callable CONTENT_UNPUBLISHED
functions/src/contents.ts — onContentUpdate / onContentDelete CONTENT_CHANGED
functions/src/translations.ts — publish callable TRANSLATION_PUBLISHED
functions/src/translations.ts / functions/src/services/translation.service.ts — generateTranslationsDraft() TRANSLATION_CHANGED

All call triggerWebHooksForEvent(spaceId, payload) from functions/src/utils/webhook-utils.ts.


Cleanup

A Firestore onDocumentDeleted trigger in functions/src/webhooks.ts recursively deletes the logs subcollection when a webhook document is deleted.


Frontend

Routes: part of the Developers module (see Open API for the sibling module and route shell)

/features/spaces/:spaceId/developers/webhooks             [DEV_WEBHOOK]  ← WebhooksComponent (list)
/features/spaces/:spaceId/developers/webhooks/:webhookId  [DEV_WEBHOOK]  ← WebhookDetailComponent

Form validation (WebhookDialogComponent):

  • name: required, 3–50 chars, no leading/trailing spaces
  • url: required, max 2048 chars, https:// (or http://localhost / http://127.0.0.1 for local development) — error key webhookUrl. firestore.rules enforces the same shape on create and update. Delivery re-checks it and blocks private addresses, see Destination restrictions.
  • events: required, at least one selected
  • secret: optional, displayed as a password field

Webhooks are created with enabled: true by default. WebhooksComponent (list view) supports enable/disable toggle, edit, delete, and navigating into a webhook's detail page.

WebhookDetailComponent shows the full log history for a single webhook: search by log id, filter by event/status, paginated (HlmPaginationImports), plus enable/disable, edit, and delete actions.


Implementation Files

File Purpose
functions/src/models/webhook.model.ts Backend types — WebHook, WebHookEvent, WebHookPayload, WebHookLog
functions/src/services/webhook.service.ts Backend Firestore queries
functions/src/utils/webhook-utils.ts HTTP dispatch, HMAC signing, execution logging
functions/src/utils/webhook-request.ts Destination checks (URL, resolved address, headers), guarded POST
functions/src/webhooks.ts onDocumentDeleted cleanup trigger
src/app/shared/models/webhook.model.ts Frontend types
src/app/shared/services/webhook.service.ts Frontend Firestore CRUD + log queries
src/app/shared/validators/webhook.validator.ts Form validators
src/app/features/spaces/developers/webhooks/ UI — list (webhooks.component), create/edit (webhook-dialog/), detail + log history (webhook-detail/)