From 0d56f6adf161dda425a6de2746a95c48e5cd56f2 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Wed, 8 Jul 2026 08:57:10 +0800 Subject: [PATCH] feat(calcom): route Cal.com egress through host OutboundHTTP (ADR 0023) Under wasm the guest has no network, so every Cal.com v2 call was dying at "dial tcp: lookup api.cal.com". Thread deps.OutboundHTTP (http.RoundTripper) from NewCalcomRouter onto CalcomHandler.rt and into NewCalcomClient, building the client with Transport: rt. A nil rt (native/DESCRIBE/test) falls back to the default transport, so bundled/httptest paths are unchanged. Declare allowed_hosts = ["api.cal.com"] (deny-by-default; admin-granted at install per the Phase 3 consent gate) and bump pluginsdk v0.2.4 -> v0.2.5 for CoreServices.OutboundHTTP. Bump 2.0.5 -> 2.0.6. Co-Authored-By: Claude Opus 4.8 --- client.go | 10 +++++++--- go.mod | 5 +++-- go.sum | 20 ++++++++++---------- handler.go | 30 +++++++++++++++++++----------- plugin.mod | 3 ++- 5 files changed, 41 insertions(+), 27 deletions(-) diff --git a/client.go b/client.go index e676f7b..e9edc3c 100644 --- a/client.go +++ b/client.go @@ -38,10 +38,14 @@ type CalcomClient struct { baseURL string } -// NewCalcomClient builds a client with the standard 30s HTTP timeout. -func NewCalcomClient(apiKey string) *CalcomClient { +// NewCalcomClient builds a client with the standard 30s HTTP timeout. rt is the +// host-mediated egress transport (deps.OutboundHTTP, ADR 0023) — under wasm the +// guest has no network, so every Cal.com request must ride it. A nil rt falls +// back to http.DefaultTransport, keeping native/DESCRIBE and httptest paths +// working (tests reach their fake server by overriding calcomBaseURL). +func NewCalcomClient(apiKey string, rt http.RoundTripper) *CalcomClient { return &CalcomClient{ - http: &http.Client{Timeout: 30 * time.Second}, + http: &http.Client{Transport: rt, Timeout: 30 * time.Second}, apiKey: apiKey, baseURL: calcomBaseURL, } diff --git a/go.mod b/go.mod index 60c8e78..602a9fe 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.dev.alexdunmow.com/block/calcomblock go 1.26.4 require ( - git.dev.alexdunmow.com/block/pluginsdk v0.2.4 + git.dev.alexdunmow.com/block/pluginsdk v0.2.5 github.com/a-h/templ v0.3.1020 github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 @@ -17,6 +17,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect golang.org/x/mod v0.37.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 65933b4..2a094c3 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -git.dev.alexdunmow.com/block/pluginsdk v0.2.2 h1:jXq6ZAIVmsKtjAGdpYQvey9Uwl+poero/V2X1MHH80c= -git.dev.alexdunmow.com/block/pluginsdk v0.2.2/go.mod h1:uz6oRurbuHdTcUxg47ymVgBqRARKlpXp1JIziXevzms= -git.dev.alexdunmow.com/block/pluginsdk v0.2.4 h1:JC+VDHOE/SzXzR3znwAJyrN8c9gKhB91cfxUQBX+jAs= -git.dev.alexdunmow.com/block/pluginsdk v0.2.4/go.mod h1:uz6oRurbuHdTcUxg47ymVgBqRARKlpXp1JIziXevzms= +git.dev.alexdunmow.com/block/pluginsdk v0.2.5 h1:01d+5stAywSENScafnNKyMc2ZM5GGFt+hCBKuKTFJW4= +git.dev.alexdunmow.com/block/pluginsdk v0.2.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= @@ -38,12 +36,14 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/handler.go b/handler.go index 37f3359..fa0015d 100644 --- a/handler.go +++ b/handler.go @@ -251,6 +251,10 @@ type CalcomHandler struct { // trusting the POST body. nil in editor-preview / direct-handler unit tests, // where blockCaptchaEnabled reports false. blockConfig blockContentResolver + // rt is the host-mediated egress transport (deps.OutboundHTTP, ADR 0023) + // every Cal.com client is built with. nil in native/DESCRIBE/test builds, + // where NewCalcomClient falls back to the default transport. + rt http.RoundTripper } // NewCalcomHandler creates a new Cal.com handler. appURL is the externally @@ -282,6 +286,10 @@ func NewCalcomRouter(deps plugin.CoreServices) http.Handler { limiter := NewRateLimiter(bookingsPerHourPerIP) helpers.StartCleanupLoop(context.Background(), 15*time.Minute, limiter.SweepStale) h := NewCalcomHandler(settings, limiter, deps.AppURL) + // Route every Cal.com call through the host-mediated egress transport. nil + // under native/DESCRIBE builds (deps has no host); the client then uses the + // default transport, which only the non-wasm paths can reach. + h.rt = deps.OutboundHTTP // blockConfig resolves the server-authoritative captcha requirement from // published block content via the content.published_block_configs // capability (pluginsdk >= v0.2.4) — the sandboxed plugin role cannot read @@ -375,8 +383,8 @@ func resetEventTZCache() { // type's pinned availability schedule when one is set, else the account's // default schedule. Returns "" when any hop fails — callers then fall down // the site-setting ladder rather than guessing. -func fetchEventTimezone(ctx context.Context, apiKey, username, eventType string) string { - client := NewCalcomClient(apiKey) +func fetchEventTimezone(ctx context.Context, rt http.RoundTripper, apiKey, username, eventType string) string { + client := NewCalcomClient(apiKey, rt) schedules, err := client.ListSchedules(ctx) if err != nil || len(schedules) == 0 { log.Printf("calcom: list schedules for %s/%s: %v", username, eventType, err) @@ -411,7 +419,7 @@ func fetchEventTimezone(ctx context.Context, apiKey, username, eventType string) func (h *CalcomHandler) eventLocation(ctx context.Context, apiKey, username, eventType string) (*time.Location, string) { tz := peekEventTZ(username, eventType) if tz == "" && apiKey != "" && username != "" && eventType != "" { - if tz = fetchEventTimezone(ctx, apiKey, username, eventType); tz != "" { + if tz = fetchEventTimezone(ctx, h.rt, apiKey, username, eventType); tz != "" { storeEventTZ(username, eventType, tz) } } @@ -458,7 +466,7 @@ func (h *CalcomHandler) HandleGetSlots(w http.ResponseWriter, r *http.Request) { } dayEnd := dayStart.AddDate(0, 0, 1) - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) slotsResp, err := client.GetSlots( r.Context(), username, @@ -554,7 +562,7 @@ func (h *CalcomHandler) HandleGetForm(w http.ResponseWriter, r *http.Request) { var bookingFields []BookingField if apiKeyErr == nil && apiKey != "" && username != "" && eventType != "" { - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) et, etErr := client.GetEventType(r.Context(), username, eventType) if etErr != nil { // Fall back to default fields so a transient Cal.com error doesn't @@ -719,7 +727,7 @@ func (h *CalcomHandler) HandleCreateBooking(w http.ResponseWriter, r *http.Reque } payload := routeBookingForm(r.Form, region) - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) br := BookingRequest{ EventTypeSlug: eventType, Username: username, @@ -832,7 +840,7 @@ func (h *CalcomHandler) HandleCancelBooking(w http.ResponseWriter, r *http.Reque return } - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) if err := client.CancelBooking(r.Context(), uid, "Cancelled by attendee"); err != nil { // Full technical cause stays server-side; the visitor sees curated copy. log.Printf("calcom: cancel booking %q: %v", uid, err) @@ -948,7 +956,7 @@ func (h *CalcomHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request // have no cached username. Fire /me once on the first settings load and // persist the result so subsequent loads are cache-hits. if username == "" && apiKey != "" { - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) if me, err := client.GetMe(ctx); err != nil { log.Printf("calcom: backfill /me in HandleGetSettings: %v", err) } else { @@ -1010,7 +1018,7 @@ func (h *CalcomHandler) HandleSaveSettings(w http.ResponseWriter, r *http.Reques // ask the admin to type their own username. Best-effort: if /me is // down or rate-limited, the save still succeeds and the admin can // refresh later via Test Connection. - client := NewCalcomClient(req.APIKey) + client := NewCalcomClient(req.APIKey, h.rt) if me, err := client.GetMe(ctx); err != nil { log.Printf("calcom: /me lookup after save: %v", err) } else if err := h.settings.SetUsername(ctx, me.Username); err != nil { @@ -1074,7 +1082,7 @@ func (h *CalcomHandler) HandleListEventTypes(w http.ResponseWriter, r *http.Requ return } username := r.URL.Query().Get("username") - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) ets, err := client.ListEventTypes(r.Context(), username) if err != nil { log.Printf("calcom: list event types: %v", err) @@ -1097,7 +1105,7 @@ func (h *CalcomHandler) HandleTestConnection(w http.ResponseWriter, r *http.Requ return } - client := NewCalcomClient(apiKey) + client := NewCalcomClient(apiKey, h.rt) ets, err := client.ListEventTypes(r.Context(), "") if err != nil { log.Printf("calcom: test connection: %v", err) diff --git a/plugin.mod b/plugin.mod index d8a2da0..7bdad71 100644 --- a/plugin.mod +++ b/plugin.mod @@ -2,8 +2,9 @@ name = "calcomblock" display_name = "Cal.com Booking" scope = "@ninja" -version = "2.0.5" +version = "2.0.6" description = "Embeddable Cal.com booking calendar block with custom styling, timezone-aware slot windowing, honeypot + captcha + rate-limited public booking endpoints, and webhook receiver." kind = "plugin" categories = ["forms"] tags = ["calcom", "booking", "calendar", "scheduling", "appointments"] +allowed_hosts = ["api.cal.com"]