api/webhook.go
Snider fb498f0b88 feat(api): canonical webhook events + chat completions transport discovery
Implements gaps between RFC.md spec and code:

- Export canonical webhook event identifiers (RFC §6) as Go constants:
  WebhookEventWorkspaceCreated, WebhookEventLinkClicked, etc. Plus
  WebhookEvents() and IsKnownWebhookEvent(name) helpers for SDK consumers
  and middleware validation.

- Surface the chat completions endpoint (RFC §11.1) through TransportConfig
  (ChatCompletionsEnabled + ChatCompletionsPath) and the OpenAPI spec
  extensions (x-chat-completions-enabled, x-chat-completions-path) so
  clients can auto-discover the local OpenAI-compatible endpoint.

- Add internal test coverage for chat completions sampling defaults
  (Gemma 4 calibrated temp=1.0, top_p=0.95, top_k=64, max_tokens=2048)
  and the ThinkingExtractor channel routing (RFC §11.6).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 15:02:18 +01:00

264 lines
8.6 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"slices"
"strconv"
"time"
core "dappco.re/go/core"
)
// Canonical webhook event identifiers from RFC §6. These constants mirror the
// PHP-side event catalogue so Go senders and receivers reference the same
// namespaced strings.
//
// evt := api.WebhookEventLinkClicked // "link.clicked"
const (
// WebhookEventWorkspaceCreated fires when a new workspace is provisioned.
WebhookEventWorkspaceCreated = "workspace.created"
// WebhookEventWorkspaceDeleted fires when a workspace is permanently removed.
WebhookEventWorkspaceDeleted = "workspace.deleted"
// WebhookEventSubscriptionChanged fires when a subscription plan changes.
WebhookEventSubscriptionChanged = "subscription.changed"
// WebhookEventSubscriptionCancelled fires when a subscription is cancelled.
WebhookEventSubscriptionCancelled = "subscription.cancelled"
// WebhookEventBiolinkCreated fires when a new biolink page is created.
WebhookEventBiolinkCreated = "biolink.created"
// WebhookEventLinkClicked fires when a tracked short link is clicked.
// High-volume event — recipients should opt in explicitly.
WebhookEventLinkClicked = "link.clicked"
// WebhookEventTicketCreated fires when a support ticket is opened.
WebhookEventTicketCreated = "ticket.created"
// WebhookEventTicketReplied fires when a support ticket receives a reply.
WebhookEventTicketReplied = "ticket.replied"
)
// WebhookEvents returns the canonical list of webhook event identifiers
// documented in RFC §6. The order is stable: catalogue groups share a prefix
// (workspace → subscription → biolink → link → ticket).
//
// for _, evt := range api.WebhookEvents() {
// registry.Enable(evt)
// }
func WebhookEvents() []string {
return []string{
WebhookEventWorkspaceCreated,
WebhookEventWorkspaceDeleted,
WebhookEventSubscriptionChanged,
WebhookEventSubscriptionCancelled,
WebhookEventBiolinkCreated,
WebhookEventLinkClicked,
WebhookEventTicketCreated,
WebhookEventTicketReplied,
}
}
// IsKnownWebhookEvent reports whether the given event name is one of the
// canonical identifiers documented in RFC §6.
//
// if !api.IsKnownWebhookEvent(evt) {
// return errors.New("unknown webhook event")
// }
func IsKnownWebhookEvent(name string) bool {
return slices.Contains(WebhookEvents(), core.Trim(name))
}
// WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook
// payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and
// a signature header so receivers can validate authenticity, integrity, and
// reject replays.
//
// The signature format mirrors the PHP-side WebhookSignature service:
//
// signature = HMAC-SHA256(timestamp + "." + payload, secret)
//
// signer := api.NewWebhookSigner("supersecret")
// headers := signer.Headers([]byte(`{"event":"workspace.created"}`))
// // headers["X-Webhook-Signature"] is the hex digest
// // headers["X-Webhook-Timestamp"] is the Unix timestamp string
type WebhookSigner struct {
secret []byte
tolerance time.Duration
}
const (
// WebhookSignatureHeader is the response/request header that carries the
// HMAC-SHA256 hex digest of a signed webhook payload.
WebhookSignatureHeader = "X-Webhook-Signature"
// WebhookTimestampHeader is the response/request header that carries the
// Unix timestamp used to derive the signature.
WebhookTimestampHeader = "X-Webhook-Timestamp"
// DefaultWebhookTolerance is the maximum age of a webhook timestamp the
// signer will accept. Five minutes mirrors the PHP-side default and
// allows for reasonable clock skew between sender and receiver.
DefaultWebhookTolerance = 5 * time.Minute
)
// NewWebhookSigner constructs a signer that uses the given shared secret with
// the default timestamp tolerance (five minutes).
//
// signer := api.NewWebhookSigner("shared-secret")
func NewWebhookSigner(secret string) *WebhookSigner {
return &WebhookSigner{
secret: []byte(secret),
tolerance: DefaultWebhookTolerance,
}
}
// NewWebhookSignerWithTolerance constructs a signer with a custom timestamp
// tolerance. A tolerance of zero or negative falls back to
// DefaultWebhookTolerance to avoid silently disabling replay protection.
//
// signer := api.NewWebhookSignerWithTolerance("secret", 30*time.Second)
func NewWebhookSignerWithTolerance(secret string, tolerance time.Duration) *WebhookSigner {
if tolerance <= 0 {
tolerance = DefaultWebhookTolerance
}
return &WebhookSigner{
secret: []byte(secret),
tolerance: tolerance,
}
}
// GenerateWebhookSecret returns a hex-encoded 32-byte random string suitable
// for use as a webhook signing secret. Output length is 64 characters.
//
// secret, err := api.GenerateWebhookSecret()
// // secret = "9f1a..." (64 hex chars)
func GenerateWebhookSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", core.E("WebhookSigner.GenerateSecret", "read random bytes", err)
}
return hex.EncodeToString(buf), nil
}
// Tolerance returns the configured maximum age tolerance for verification.
//
// d := signer.Tolerance()
func (s *WebhookSigner) Tolerance() time.Duration {
if s == nil || s.tolerance <= 0 {
return DefaultWebhookTolerance
}
return s.tolerance
}
// Sign returns the hex-encoded HMAC-SHA256 of "timestamp.payload" using the
// signer's secret. Callers may pass any non-nil payload bytes (typically a
// JSON-encoded event body).
//
// digest := signer.Sign(payload, time.Now().Unix())
func (s *WebhookSigner) Sign(payload []byte, timestamp int64) string {
if s == nil {
return ""
}
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(strconv.FormatInt(timestamp, 10)))
mac.Write([]byte("."))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// SignNow signs the payload using the current Unix timestamp and returns
// both the signature and the timestamp used.
//
// sig, ts := signer.SignNow(payload)
func (s *WebhookSigner) SignNow(payload []byte) (string, int64) {
now := time.Now().Unix()
return s.Sign(payload, now), now
}
// Headers returns the HTTP headers a sender should attach to a webhook
// request: X-Webhook-Signature and X-Webhook-Timestamp. The current Unix
// timestamp is used.
//
// for k, v := range signer.Headers(payload) {
// req.Header.Set(k, v)
// }
func (s *WebhookSigner) Headers(payload []byte) map[string]string {
sig, ts := s.SignNow(payload)
return map[string]string{
WebhookSignatureHeader: sig,
WebhookTimestampHeader: strconv.FormatInt(ts, 10),
}
}
// Verify reports whether the given signature matches the payload and the
// timestamp is within the signer's tolerance window. Comparison uses
// hmac.Equal to avoid timing attacks.
//
// if signer.Verify(payload, sig, ts) {
// // accept event
// }
func (s *WebhookSigner) Verify(payload []byte, signature string, timestamp int64) bool {
if s == nil {
return false
}
if !s.IsTimestampValid(timestamp) {
return false
}
expected := s.Sign(payload, timestamp)
return hmac.Equal([]byte(expected), []byte(signature))
}
// VerifySignatureOnly compares the signature without checking timestamp
// freshness. Use it when timestamp validation is performed elsewhere or in
// tests where a stable timestamp is required.
//
// ok := signer.VerifySignatureOnly(payload, sig, fixedTimestamp)
func (s *WebhookSigner) VerifySignatureOnly(payload []byte, signature string, timestamp int64) bool {
if s == nil {
return false
}
expected := s.Sign(payload, timestamp)
return hmac.Equal([]byte(expected), []byte(signature))
}
// IsTimestampValid reports whether the given Unix timestamp is within the
// signer's configured tolerance window relative to the current time.
//
// if !signer.IsTimestampValid(ts) {
// return errors.New("webhook timestamp expired")
// }
func (s *WebhookSigner) IsTimestampValid(timestamp int64) bool {
tol := s.Tolerance()
now := time.Now().Unix()
delta := now - timestamp
if delta < 0 {
delta = -delta
}
return time.Duration(delta)*time.Second <= tol
}
// VerifyRequest extracts the signature and timestamp headers from a request
// and verifies the given payload against them. Returns false when the
// headers are missing, malformed, or the signature does not match.
//
// if !signer.VerifyRequest(r, payload) {
// http.Error(w, "invalid signature", http.StatusUnauthorized)
// return
// }
func (s *WebhookSigner) VerifyRequest(r *http.Request, payload []byte) bool {
if r == nil {
return false
}
sig := r.Header.Get(WebhookSignatureHeader)
rawTS := r.Header.Get(WebhookTimestampHeader)
if sig == "" || rawTS == "" {
return false
}
ts, err := strconv.ParseInt(rawTS, 10, 64)
if err != nil {
return false
}
return s.Verify(payload, sig, ts)
}