Class: Portage::Ucp::Rack::WebhookEndpoint

Inherits:
Object
  • Object
show all
Defined in:
lib/portage/ucp/rack/webhook_endpoint.rb

Overview

Receives backend order-lifecycle webhooks (§11). Verifies the HMAC signature against the raw body before parsing anything, per the standard webhook guardrail: never trust a payload before you've authenticated it. Normalizes to a Portage::Ucp::Order and hands off to a consumer-supplied on_order_event callback — the gem doesn't assume anything about how the consumer stores or reacts to order events.

Emits Observability events (§12) directly via logger: rather than a new config.event_sink seam (§23 step 5): this endpoint is a plain Rack app, not built through Mcp::Server.build, so it never runs inside an MCP request and mcp's own around_request/instrumentation_callback hooks can't see it — the same reason CheckoutState (§23 step 3) needed a logger threaded to it, not a reason to invent a second config option: logger: already exists on Portage::Ucp.configuration and every other collaborator that logs takes it the same way.

Phase 3 of docs/plans/proxy-support.md: trusted_proxies: lets a consumer behind a reverse proxy get the real client IP into its own logs/on_order_event handling (ForwardedRequest#client_ip, fail- closed with no config change otherwise) and, when passthrough_headers: is also set, forwards that request's allowlisted headers (trusted peer only) to any outbound call on_order_event makes via Support::Connection, through the same fiber-local Support::PassthroughContext CallEndpoint uses — cleared again once on_order_event returns.

Instance Method Summary collapse

Constructor Details

#initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE", logger: Portage::Ucp.configuration.logger, trusted_proxies: [], passthrough_headers: [], passthrough_forwarded: "drop") ⇒ WebhookEndpoint

Returns a new instance of WebhookEndpoint.

Parameters:

  • trusted_proxies (Array<String>) (defaults to: []) —

    CIDRs of the consumer's own reverse proxy/load balancer — see ForwardedRequest.

  • passthrough_headers (Array<String>) (defaults to: []) —

    inbound header names to forward to outbound calls made from on_order_event, from a trusted peer only. Never a protected header (raises at construction time — see ForwardedRequest.validate_passthrough!).

  • passthrough_forwarded ("append", "replace", "drop") (defaults to: "drop") —

    see Support::PassthroughContext.



43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/portage/ucp/rack/webhook_endpoint.rb', line 43

def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
               logger: Portage::Ucp.configuration.logger, trusted_proxies: [],
               passthrough_headers: [], passthrough_forwarded: "drop")
  @secret = secret
  @on_order_event = on_order_event
  @signature_header = signature_header
  @logger = logger
  @trusted_proxies = trusted_proxies
  @passthrough_headers = passthrough_headers
  @passthrough_forwarded = passthrough_forwarded
  ForwardedRequest.validate_passthrough!(@passthrough_headers)
end

Instance Method Details

#call(env) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/portage/ucp/rack/webhook_endpoint.rb', line 56

def call(env)
  request = ::Rack::Request.new(env)
  return respond(404, error: "not_found") unless request.post?

  forwarded = ForwardedRequest.new(request, trusted_proxies: @trusted_proxies)
  body = request.body.read
  unless valid_signature?(body, env[@signature_header])
    Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature",
                                                                       client_ip: forwarded.client_ip)
    return respond(401, error: "invalid_signature")
  end

  handle_order_event(body, forwarded)
end