diff --git a/adapters/stripe-style/scripts/lib.star b/adapters/stripe-style/scripts/lib.star index 4122e6f6..36685bf1 100644 --- a/adapters/stripe-style/scripts/lib.star +++ b/adapters/stripe-style/scripts/lib.star @@ -47,13 +47,15 @@ def _tc_clear(clock_id): store_kv_delete("stripe", "tc_active") # _signed_emit MACs the exact on-wire body and delivers with Stripe-Signature. -# The same (event_type, payload) feeds events_body (signing input) and -# events_emit (delivery), so the signature verifies against the bytes the sink -# receives. Stripe signs "{timestamp}.{body}" and carries t=,v1= in the header. +# The DELIVERED body is the full Stripe event object (id/object/type/data), +# exactly like a real Stripe webhook POST — so receiver-side SDK event parsers +# (stripe-node webhooks.constructEvent etc.) and signature verification both +# run against the real shape. The same serialized bytes feed the MAC and the +# delivery, so the signature verifies against what the sink receives. Stripe +# signs "{timestamp}.{body}" and carries t=,v1= in the header. # -# Every emitted event is ALSO recorded in the "events" collection with -# Stripe's event-object shape, so GET /v1/events (scripts/events.star) lists -# exactly the event types the webhook sink receives. +# The event is ALSO recorded in the "events" collection (the same object), so +# GET /v1/events (scripts/events.star) lists exactly what the sink receives. def _signed_emit(event_type, payload): t = _now() ev = { @@ -75,9 +77,9 @@ def _signed_emit(event_type, payload): # above is still recorded either way, like real Stripe's GET /v1/events. if not _events_enabled(event_type): return - body = events_body(event_type, payload) + body = json.encode(ev) sig = crypto.hmac_sha256(_WEBHOOK_SECRET, str(t) + "." + body) - events_emit(event_type, payload, {"Stripe-Signature": "t=" + str(t) + ",v1=" + sig}) + events_emit_raw(event_type, body, {"Stripe-Signature": "t=" + str(t) + ",v1=" + sig}) # _events_enabled reports whether event_type should be delivered to the # configured webhook sink. True when no webhook endpoints are registered diff --git a/internal/adapter/runtime/identity_events_test.go b/internal/adapter/runtime/identity_events_test.go index 267b152c..227d7fca 100644 --- a/internal/adapter/runtime/identity_events_test.go +++ b/internal/adapter/runtime/identity_events_test.go @@ -2,10 +2,12 @@ package runtime_test import ( "encoding/json" + "io" "net/http" "net/http/httptest" "sync" "testing" + "time" "stuntapi.com/stunt/internal/adapter/runtime" "stuntapi.com/stunt/internal/primitives/events" @@ -351,6 +353,107 @@ def on_post(req): } } +// --- events_emit_raw delivers the exact bytes --- + +// TestEventsEmitRawVerbatimBody proves events_emit_raw delivers the caller's +// body string VERBATIM — no {type, payload} envelope — so providers whose +// webhook receivers parse the provider's own event-object shape (Stripe, +// GitHub, …) get the real structure on the wire, and signature schemes that +// MAC the raw bytes verify against what the sink actually received. +func TestEventsEmitRawVerbatimBody(t *testing.T) { + var mu sync.Mutex + type delivery struct { + body string + header http.Header + } + var deliveries []delivery + + sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + deliveries = append(deliveries, delivery{body: string(b), header: r.Header.Clone()}) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer sink.Close() + + emitter := events.NewEmitter() + defer emitter.Close() + builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{ + Emitter: emitter, + ServiceName: "test-svc", + }) + + src := ` +def on_post(req): + events_register(req["body"]["url"]) + body = json.encode({"id": "evt_1", "object": "event", "type": "charge.created", "data": {"object": {"id": "ch_1"}}}) + events_emit_raw("charge.created", body, {"Stripe-Signature": "t=1,v1=abc"}) + events_emit("plain", {"n": 1}) + return respond(200, {"ok": True}) +` + vm, err := starlark.Load(src, builtins) + if err != nil { + t.Fatalf("Load: %v", err) + } + + resp, err := vm.Call("on_post", starlark.Request{ + Method: "POST", + Body: map[string]any{"url": sink.URL}, + }) + if err != nil { + t.Fatalf("Call: %v", err) + } + if resp.Body["ok"] != true { + t.Fatalf("ok = %v, want true", resp.Body["ok"]) + } + + time.Sleep(200 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if len(deliveries) != 2 { + t.Fatalf("sink received %d bodies, want 2", len(deliveries)) + } + var rawDeliveries, envelopeDeliveries int + var rawHeader http.Header + for _, d := range deliveries { + b := d.body + var parsed map[string]any + if err := json.Unmarshal([]byte(b), &parsed); err != nil { + t.Fatalf("delivery not JSON: %v (%s)", err, b) + } + if obj, ok := parsed["object"].(string); ok && obj == "event" { + rawDeliveries++ + if parsed["id"] != "evt_1" || parsed["type"] != "charge.created" { + t.Errorf("raw body altered: %s", b) + } + data, ok := parsed["data"].(map[string]any) + if !ok { + t.Fatalf("raw body data = %v, want dict", parsed["data"]) + } + inner, ok := data["object"].(map[string]any) + if !ok || inner["id"] != "ch_1" { + t.Errorf("data.object.id = %v, want ch_1", data["object"]) + } + if _, wrapped := parsed["payload"]; wrapped { + t.Errorf("raw delivery still wrapped in an envelope: %s", b) + } + rawHeader = d.header + } else if _, ok := parsed["type"]; ok && parsed["type"] == "plain" { + envelopeDeliveries++ + if _, ok := parsed["payload"]; !ok { + t.Errorf("events_emit delivery lost its envelope payload: %s", b) + } + } + } + if rawDeliveries != 1 || envelopeDeliveries != 1 { + t.Fatalf("deliveries: %d raw, %d envelope; want 1 each", rawDeliveries, envelopeDeliveries) + } + if rawHeader.Get("Stripe-Signature") != "t=1,v1=abc" { + t.Errorf("Stripe-Signature = %q, want the caller header on the raw delivery", rawHeader.Get("Stripe-Signature")) + } +} + // --- events_emit before register errors --- // TestEventsEmitBeforeRegister proves that calling events_emit without first diff --git a/internal/adapter/runtime/runtime.go b/internal/adapter/runtime/runtime.go index 7feaa6ca..46dd781d 100644 --- a/internal/adapter/runtime/runtime.go +++ b/internal/adapter/runtime/runtime.go @@ -556,6 +556,32 @@ func buildEventsBuiltins(emitter *events.Emitter, serviceName string) sk.StringD } return sk.String(string(body)), nil }), + "events_emit_raw": sk.NewBuiltin("events_emit_raw", func(_ *sk.Thread, _ *sk.Builtin, args sk.Tuple, kwargs []sk.Tuple) (sk.Value, error) { + var eventType string + var body string + var headersVal sk.Value = sk.None + if err := sk.UnpackArgs("events_emit_raw", args, kwargs, "event_type", &eventType, "body", &body, "headers?", &headersVal); err != nil { + return nil, err + } + if emitter == nil { + return nil, fmt.Errorf("events_emit_raw: no events emitter configured") + } + var headers map[string]string + if headersVal != sk.None { + hd, ok := headersVal.(*sk.Dict) + if !ok { + return nil, fmt.Errorf("events_emit_raw: headers must be a dict, got %s", headersVal.Type()) + } + headers = starlark.ToStringMap(hd) + } + ctx, cancel := context.WithTimeout(context.Background(), eventsEmitTimeout) + defer cancel() + // Fire-and-forget, like events_emit. The body string is delivered + // verbatim (no {type, payload} envelope) for providers whose + // receivers parse the provider's own event-object shape. + _ = emitter.EmitRaw(ctx, serviceName, eventType, []byte(body), headers) + return sk.None, nil + }), } } diff --git a/internal/cli/llm.go b/internal/cli/llm.go index ce2c8e3f..d829a914 100644 --- a/internal/cli/llm.go +++ b/internal/cli/llm.go @@ -118,7 +118,7 @@ Builtins: # issue a verifiable JWT: hdr=crypto.base64url_encode(alg_rs256_kid); pl=crypto.base64url_encode(claims); # jwt=hdr+"."+pl+"."+crypto.rsa_sign(priv, hdr+"."+pl, encoding="base64url") v = json_safe_decode(s) # total JSON decode: value or None (never raises — for untrusted JWT claims/multipart parts) - json.loads(s) / json.dumps(obj) # json module predeclared + json.decode(s) / json.encode(obj) # json module predeclared lib.star in scripts/ is PRELOADED — its defs are shared across handlers. NO load(). NO fs/net/import. Gotchas: literal routes before param routes; routes support embedded params like /accounts({id}) (OData); req.raw_body is the verbatim request bytes (use store_blob for byte-exact binary uploads); diff --git a/internal/engine/stripe_adapter_test.go b/internal/engine/stripe_adapter_test.go index 356742a4..b431a5fd 100644 --- a/internal/engine/stripe_adapter_test.go +++ b/internal/engine/stripe_adapter_test.go @@ -559,10 +559,10 @@ func TestStripeStyleAuthAndWebhooks(t *testing.T) { var foundCreated bool for _, env := range receivedEvents { if env["type"] == "charge.created" { - payload, ok := env["payload"].(map[string]any) + payload, ok := env["data"].(map[string]any)["object"].(map[string]any) if !ok { mu.Unlock() - t.Fatalf("charge.created payload = %v, want a dict", env["payload"]) + t.Fatalf("charge.created data.object = %v, want a dict", env["data"]) } if payload["id"] == chargeID { foundCreated = true diff --git a/internal/engine/stripe_connect_test.go b/internal/engine/stripe_connect_test.go index 3877e7e0..da286e05 100644 --- a/internal/engine/stripe_connect_test.go +++ b/internal/engine/stripe_connect_test.go @@ -719,7 +719,7 @@ func TestStripeStylePayoutAccountScoping(t *testing.T) { if err := json.Unmarshal([]byte(bodies[0]), &env); err != nil { t.Fatalf("webhook body is not JSON: %v (%s)", err, bodies[0]) } - payload, ok := env["payload"].(map[string]any) + payload, ok := env["data"].(map[string]any)["object"].(map[string]any) if !ok { t.Fatalf("webhook body has no payload object: %s", bodies[0]) } diff --git a/internal/primitives/events/events.go b/internal/primitives/events/events.go index 2c7a6429..43ec3b2e 100644 --- a/internal/primitives/events/events.go +++ b/internal/primitives/events/events.go @@ -128,6 +128,21 @@ func (e *Emitter) Close() { // maxRetries times with exponential-ish backoff. Returns the last error // if all attempts fail. func (e *Emitter) Emit(ctx context.Context, ns, eventType string, payload map[string]any, headers map[string]string) error { + body, err := MarshalEnvelope(eventType, payload) + if err != nil { + return fmt.Errorf("events: marshal envelope: %w", err) + } + return e.EmitRaw(ctx, ns, eventType, body, headers) +} + +// EmitRaw delivers an exact pre-marshaled body to the service's registered +// target. Providers whose webhook receivers parse the delivery as the +// provider's own event object (Stripe, GitHub, …) need the real shape on the +// wire — not the {type, payload} envelope — so signature schemes that MAC the +// raw bytes and SDK event parsers both verify against what the sink receives. +// eventType names the event for registration bookkeeping only; it does not +// appear in the body. Retry/header/validation semantics match Emit. +func (e *Emitter) EmitRaw(ctx context.Context, ns, eventType string, body []byte, headers map[string]string) error { e.mu.RLock() url, ok := e.targets[ns] e.mu.RUnlock() @@ -135,11 +150,6 @@ func (e *Emitter) Emit(ctx context.Context, ns, eventType string, payload map[st return fmt.Errorf("events: emit %s/%s: %w", ns, eventType, ErrNotRegistered) } - body, err := MarshalEnvelope(eventType, payload) - if err != nil { - return fmt.Errorf("events: marshal envelope: %w", err) - } - // Fail fast on bad caller headers: these are permanent errors (a malformed // header or a reserved name), not transient delivery failures, so they // must short-circuit before the retry loop sends anything.