Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions adapters/stripe-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
103 changes: 103 additions & 0 deletions internal/adapter/runtime/identity_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions internal/adapter/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions internal/engine/stripe_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/stripe_connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
20 changes: 15 additions & 5 deletions internal/primitives/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,28 @@ 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()
if !ok {
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.
Expand Down
Loading