From cd8c717363b0d23bb876d067add7ed6a1aa5345d Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Thu, 11 Jun 2026 11:43:56 +0300 Subject: [PATCH] chore(version): bump to 0.122.47 - refactor(turn): extract decodeTURNConfig for testability - feat(turn): add stealth domain fields to config - fix(apns): nest custom data under "body" for expo-notifications compatibility --- VERSION | 2 +- core/cmd/turn/config.go | 77 ++++++++++++++--------- core/cmd/turn/config_test.go | 60 ++++++++++++++++++ core/pkg/push/providers/apns/apns.go | 44 ++++++++++--- core/pkg/push/providers/apns/apns_test.go | 57 +++++++++++++---- core/pkg/push/providers/ntfy/ntfy.go | 53 ++++++++++------ core/pkg/push/providers/ntfy/ntfy_test.go | 52 +++++++++------ sdk/package.json | 2 +- 8 files changed, 261 insertions(+), 86 deletions(-) create mode 100644 core/cmd/turn/config_test.go diff --git a/VERSION b/VERSION index 6cad144..2a9a894 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.122.46 +0.122.47 diff --git a/core/cmd/turn/config.go b/core/cmd/turn/config.go index a302c2b..f67e10f 100644 --- a/core/cmd/turn/config.go +++ b/core/cmd/turn/config.go @@ -39,19 +39,6 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config { } } - type yamlCfg struct { - ListenAddr string `yaml:"listen_addr"` - TURNSListenAddr string `yaml:"turns_listen_addr"` - PublicIP string `yaml:"public_ip"` - Realm string `yaml:"realm"` - AuthSecret string `yaml:"auth_secret"` - RelayPortStart int `yaml:"relay_port_start"` - RelayPortEnd int `yaml:"relay_port_end"` - Namespace string `yaml:"namespace"` - TLSCertPath string `yaml:"tls_cert_path"` - TLSKeyPath string `yaml:"tls_key_path"` - } - data, err := os.ReadFile(configPath) if err != nil { logger.ComponentError(logging.ComponentTURN, "Config file not found", @@ -60,26 +47,13 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config { os.Exit(1) } - var y yamlCfg - if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil { + cfg, err := decodeTURNConfig(data) + if err != nil { logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err)) fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err) os.Exit(1) } - cfg := &turn.Config{ - ListenAddr: y.ListenAddr, - TURNSListenAddr: y.TURNSListenAddr, - PublicIP: y.PublicIP, - Realm: y.Realm, - AuthSecret: y.AuthSecret, - RelayPortStart: y.RelayPortStart, - RelayPortEnd: y.RelayPortEnd, - Namespace: y.Namespace, - TLSCertPath: y.TLSCertPath, - TLSKeyPath: y.TLSKeyPath, - } - if errs := cfg.Validate(); len(errs) > 0 { fmt.Fprintf(os.Stderr, "\nTURN configuration errors (%d):\n", len(errs)) for _, e := range errs { @@ -98,3 +72,50 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config { return cfg } + +// decodeTURNConfig strictly decodes the TURN YAML the namespace spawner writes +// (yaml.Marshal of turn.Config) into a turn.Config. The yamlCfg struct MUST +// carry every yaml-tagged field turn.Config marshals — DecodeStrict rejects +// unknown keys, so a missing field crashes the TURN binary at startup. +// Extracted (no os.Exit) so the spawner-output ↔ parser contract is unit- +// testable (see config_test.go). +func decodeTURNConfig(data []byte) (*turn.Config, error) { + type yamlCfg struct { + ListenAddr string `yaml:"listen_addr"` + TURNSListenAddr string `yaml:"turns_listen_addr"` + PublicIP string `yaml:"public_ip"` + Realm string `yaml:"realm"` + AuthSecret string `yaml:"auth_secret"` + RelayPortStart int `yaml:"relay_port_start"` + RelayPortEnd int `yaml:"relay_port_end"` + Namespace string `yaml:"namespace"` + TLSCertPath string `yaml:"tls_cert_path"` + TLSKeyPath string `yaml:"tls_key_path"` + // feat-124 stealth TURNS-over-:443: second cert served by SNI. + StealthDomain string `yaml:"stealth_domain"` + TLSStealthCertPath string `yaml:"tls_stealth_cert_path"` + TLSStealthKeyPath string `yaml:"tls_stealth_key_path"` + } + + var y yamlCfg + if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil { + return nil, err + } + + return &turn.Config{ + ListenAddr: y.ListenAddr, + TURNSListenAddr: y.TURNSListenAddr, + PublicIP: y.PublicIP, + Realm: y.Realm, + AuthSecret: y.AuthSecret, + RelayPortStart: y.RelayPortStart, + RelayPortEnd: y.RelayPortEnd, + Namespace: y.Namespace, + TLSCertPath: y.TLSCertPath, + TLSKeyPath: y.TLSKeyPath, + + StealthDomain: y.StealthDomain, + TLSStealthCertPath: y.TLSStealthCertPath, + TLSStealthKeyPath: y.TLSStealthKeyPath, + }, nil +} diff --git a/core/cmd/turn/config_test.go b/core/cmd/turn/config_test.go new file mode 100644 index 0000000..8b8fdff --- /dev/null +++ b/core/cmd/turn/config_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "testing" + + "github.com/DeBrosOfficial/network/pkg/turn" + "gopkg.in/yaml.v3" +) + +// TestDecodeTURNConfig_acceptsSpawnerOutput is the regression guard for the +// feat-124 crash: the namespace spawner writes the TURN config via +// yaml.Marshal(turn.Config), and the TURN binary parses it with a STRICT +// decoder. If turn.Config gains a yaml field the parser doesn't know, strict +// decode rejects it and TURN crash-loops at startup. This pins that the +// spawner's exact output round-trips through the parser, including the stealth +// fields. +func TestDecodeTURNConfig_acceptsSpawnerOutput(t *testing.T) { + src := turn.Config{ + ListenAddr: "0.0.0.0:3478", + TURNSListenAddr: "0.0.0.0:5349", + PublicIP: "203.0.113.7", + Realm: "orama-devnet.network", + AuthSecret: "secret", + RelayPortStart: 49152, + RelayPortEnd: 49951, + Namespace: "anchat-test", + TLSCertPath: "/x/turn-cert.pem", + TLSKeyPath: "/x/turn-key.pem", + StealthDomain: "cdn-3259254d4d3e.orama-devnet.network", + TLSStealthCertPath: "/var/lib/caddy/caddy/certificates/.../wildcard_.orama-devnet.network.crt", + TLSStealthKeyPath: "/var/lib/caddy/caddy/certificates/.../wildcard_.orama-devnet.network.key", + } + + data, err := yaml.Marshal(src) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + got, err := decodeTURNConfig(data) + if err != nil { + t.Fatalf("strict decode of spawner output failed — TURN would crash-loop at startup: %v\n---\n%s", err, data) + } + + if got.StealthDomain != src.StealthDomain || + got.TLSStealthCertPath != src.TLSStealthCertPath || + got.TLSStealthKeyPath != src.TLSStealthKeyPath { + t.Errorf("stealth fields did not round-trip: got %+v", got) + } + if got.AuthSecret != src.AuthSecret || got.TURNSListenAddr != src.TURNSListenAddr { + t.Errorf("core fields did not round-trip: got %+v", got) + } +} + +// TestDecodeTURNConfig_rejectsUnknownField confirms the strict decoder still +// rejects genuinely-unknown keys (so the contract above is meaningful). +func TestDecodeTURNConfig_rejectsUnknownField(t *testing.T) { + if _, err := decodeTURNConfig([]byte("listen_addr: \"0.0.0.0:3478\"\nbogus_field: 1\n")); err == nil { + t.Fatal("expected strict decode to reject an unknown field") + } +} diff --git a/core/pkg/push/providers/apns/apns.go b/core/pkg/push/providers/apns/apns.go index a6c5e93..e73049b 100644 --- a/core/pkg/push/providers/apns/apns.go +++ b/core/pkg/push/providers/apns/apns.go @@ -149,7 +149,7 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error { if p.kind != KindVoIP && !hasVisibleContent(msg) { return push.ErrEmptyContent } - payload, err := buildAPSPayload(msg) + payload, err := buildAPSPayload(msg, p.kind) if err != nil { return fmt.Errorf("apns: build payload: %w", err) } @@ -281,12 +281,25 @@ func tokenPrefix(token string) string { return token[:8] + "..." } -// buildAPSPayload assembles the APNs JSON payload from a generic -// PushMessage. The `aps` dictionary is the Apple-required wrapper; -// custom fields (`data`) go alongside at the top level. +// buildAPSPayload assembles the APNs JSON payload from a generic PushMessage. +// The `aps` dictionary is the Apple-required wrapper; custom `Data` placement +// depends on the kind: +// +// - KindAlert: custom data is nested under a top-level "body" object. +// expo-notifications' iOS serializer sets content.data ONLY from +// userInfo["body"] for remote notifications (NotificationRecords.swift: +// `if isRemote { return userInfo["body"] }`) — top-level sibling keys of +// `aps` are IGNORED, so spreading them there yields content.data=null on +// iOS. This was bugboard #38 (Data never reached the JS client despite +// correct wire serialization). Note: "body" here is the data envelope +// expo expects; it is distinct from the human-readable alert body, which +// lives at aps.alert.body. +// - KindVoIP: custom data stays at the top level. PushKit/CallKit pushes are +// handled by the app's native pushRegistry (not expo-notifications), which +// reads payload.dictionaryPayload directly. // // Reference: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification -func buildAPSPayload(msg push.PushMessage) ([]byte, error) { +func buildAPSPayload(msg push.PushMessage, kind Kind) ([]byte, error) { alert := map[string]string{} if msg.Title != "" { alert["title"] = msg.Title @@ -338,13 +351,28 @@ func buildAPSPayload(msg push.PushMessage) ([]byte, error) { } } root := map[string]interface{}{"aps": aps} + + // Collect tenant custom data, excluding reserved keys: `aps` (must not be + // clobbered) and `content_available` (already mapped into aps above). + data := map[string]interface{}{} for k, v := range msg.Data { - // Don't allow tenant data to clobber `aps`, and skip the - // content_available marker since we mapped it to aps above. if k == "aps" || k == "content_available" { continue } - root[k] = v + data[k] = v + } + + if len(data) > 0 { + if kind == KindVoIP { + // Native PushKit reads the dictionary payload directly — top-level. + for k, v := range data { + root[k] = v + } + } else { + // expo-notifications surfaces content.data from userInfo["body"] + // only (bugboard #38) — nest the data envelope there. + root["body"] = data + } } return json.Marshal(root) } diff --git a/core/pkg/push/providers/apns/apns_test.go b/core/pkg/push/providers/apns/apns_test.go index 05764b3..ccb3f15 100644 --- a/core/pkg/push/providers/apns/apns_test.go +++ b/core/pkg/push/providers/apns/apns_test.go @@ -155,7 +155,7 @@ func TestValidator_RedactNeverEchoesP8Key(t *testing.T) { func TestBuildAPSPayload_basicAlert(t *testing.T) { msg := push.PushMessage{Title: "hi", Body: "from orama"} - raw, err := buildAPSPayload(msg) + raw, err := buildAPSPayload(msg, KindAlert) if err != nil { t.Fatalf("build: %v", err) } @@ -174,23 +174,58 @@ func TestBuildAPSPayload_basicAlert(t *testing.T) { } } -func TestBuildAPSPayload_dataAlongsideAPS(t *testing.T) { +// Bugboard #38: for an ALERT push, custom data must be nested under a +// top-level "body" object — expo-notifications' iOS serializer reads +// content.data from userInfo["body"] only, ignoring top-level sibling keys. +func TestBuildAPSPayload_alertNestsDataUnderBody(t *testing.T) { msg := push.PushMessage{ Title: "x", Body: "y", Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"}, } - raw, _ := buildAPSPayload(msg) + raw, _ := buildAPSPayload(msg, KindAlert) var out map[string]interface{} - _ = json.Unmarshal(raw, &out) + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("payload not valid JSON: %v", err) + } if _, hasAPS := out["aps"]; !hasAPS { t.Error("payload missing aps") } - if out["thread"] != "abc" { - t.Errorf("data.thread missing; got %v", out) + // Must NOT be at the top level (expo would ignore it there). + if _, leaked := out["thread"]; leaked { + t.Errorf("data leaked to top level; expo-notifications would drop it: %v", out) } - if out["deeplink"] != "anchat://room/42" { - t.Errorf("data.deeplink missing; got %v", out) + body, ok := out["body"].(map[string]interface{}) + if !ok { + t.Fatalf("alert data not nested under top-level \"body\" object; got %v", out) + } + if body["thread"] != "abc" || body["deeplink"] != "anchat://room/42" { + t.Errorf("body envelope missing data; got %v", body) + } + // The human-readable alert body stays under aps.alert.body, distinct from + // the data envelope key. + aps := out["aps"].(map[string]interface{}) + if alert, ok := aps["alert"].(map[string]interface{}); !ok || alert["body"] != "y" { + t.Errorf("aps.alert.body should be the human-readable body; got %v", aps["alert"]) + } +} + +// VoIP pushes are handled by native PushKit (not expo-notifications), so +// custom data stays at the top level of the dictionary payload. +func TestBuildAPSPayload_voipKeepsDataTopLevel(t *testing.T) { + msg := push.PushMessage{ + Data: map[string]interface{}{"callId": "c-1", "callerName": "Alice"}, + } + raw, _ := buildAPSPayload(msg, KindVoIP) + var out map[string]interface{} + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("payload not valid JSON: %v", err) + } + if out["callId"] != "c-1" || out["callerName"] != "Alice" { + t.Errorf("voip data must stay top-level for PushKit; got %v", out) + } + if _, nested := out["body"]; nested { + t.Errorf("voip data must NOT be nested under body; got %v", out) } } @@ -199,7 +234,7 @@ func TestBuildAPSPayload_dataCannotClobberAPS(t *testing.T) { Title: "x", Data: map[string]interface{}{"aps": "evil"}, } - raw, _ := buildAPSPayload(msg) + raw, _ := buildAPSPayload(msg, KindAlert) var out map[string]interface{} _ = json.Unmarshal(raw, &out) apsField, ok := out["aps"] @@ -215,7 +250,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) { msg := push.PushMessage{ Title: "x", Badge: 3, Sound: "ding.caf", } - raw, _ := buildAPSPayload(msg) + raw, _ := buildAPSPayload(msg, KindAlert) if !strings.Contains(string(raw), `"badge":3`) { t.Errorf("badge not in payload: %s", raw) } @@ -226,7 +261,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) { func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) { msg := push.PushMessage{Title: "x", Channel: "messages"} - raw, _ := buildAPSPayload(msg) + raw, _ := buildAPSPayload(msg, KindAlert) if !strings.Contains(string(raw), `"thread-id":"messages"`) { t.Errorf("channel not mapped to thread-id: %s", raw) } diff --git a/core/pkg/push/providers/ntfy/ntfy.go b/core/pkg/push/providers/ntfy/ntfy.go index 4fde81f..3af660b 100644 --- a/core/pkg/push/providers/ntfy/ntfy.go +++ b/core/pkg/push/providers/ntfy/ntfy.go @@ -1,18 +1,28 @@ // Package ntfy implements a push.PushProvider backed by an ntfy server. // // ntfy delivers notifications via plain HTTP POST to /. -// We map PushMessage fields to ntfy headers: -// - Title -> "Title" -// - Priority -> "Priority" -// - Channel -> "Tags" -// - Data -> base64-encoded JSON in "X-Data" +// We map PushMessage fields to the ntfy publish surface: +// - Title -> "Title" header +// - Priority -> "Priority" header +// - Channel -> "Tags" header +// - Body -> the POST body (ntfy's "message", relayed verbatim) +// - Data -> the POST body as JSON, ONLY when Body is empty // -// See https://docs.ntfy.sh/publish/#publish-as-json for details. +// IMPORTANT (bugboard #126): ntfy does NOT relay arbitrary `X-*` request +// headers into the subscriber stream — only its recognized publish headers +// (Title, Priority, Tags, Click, Actions, Attach, …) and the message body +// reach the client. So structured Data and a numeric Badge cannot be carried +// as custom headers; the only field a subscriber reliably receives besides +// title/priority/tags is the message BODY. We therefore deliver Data through +// the body (UnifiedPush convention: the body IS the payload). A caller that +// sets an explicit Body owns it — to ship structured data alongside a +// human-readable body, encode both into the Body envelope. +// +// See https://docs.ntfy.sh/publish/ for the recognized header set. package ntfy import ( "context" - "encoding/base64" "encoding/json" "fmt" "io" @@ -79,7 +89,20 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(msg.Body)) + // Determine the POST body — the only structured payload ntfy relays to + // subscribers (bugboard #126). A caller-supplied Body wins; otherwise, if + // there's structured Data, serialize it as the body so a data-only push + // still reaches the client (UnifiedPush convention: body == payload). + body := msg.Body + if body == "" && len(msg.Data) > 0 { + b, err := json.Marshal(msg.Data) + if err != nil { + return fmt.Errorf("ntfy: marshal data: %w", err) + } + body = string(b) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(body)) if err != nil { return fmt.Errorf("ntfy: build request: %w", err) } @@ -96,16 +119,10 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error { // ntfy uses "Tags" for both visual emoji and operator-defined tags. req.Header.Set("Tags", msg.Channel) } - if msg.Badge > 0 { - req.Header.Set("X-Badge", fmt.Sprintf("%d", msg.Badge)) - } - if len(msg.Data) > 0 { - b, err := json.Marshal(msg.Data) - if err != nil { - return fmt.Errorf("ntfy: marshal data: %w", err) - } - req.Header.Set("X-Data", base64.StdEncoding.EncodeToString(b)) - } + // NOTE: Badge and arbitrary Data are intentionally NOT sent as custom + // headers — ntfy does not relay `X-*` headers to subscribers (#126), so + // doing so silently drops them. Data rides the body (above); a badge + // count, if needed, must be encoded into the body by the caller. if p.authToken != "" { req.Header.Set("Authorization", "Bearer "+p.authToken) } diff --git a/core/pkg/push/providers/ntfy/ntfy_test.go b/core/pkg/push/providers/ntfy/ntfy_test.go index a1d4586..af6a330 100644 --- a/core/pkg/push/providers/ntfy/ntfy_test.go +++ b/core/pkg/push/providers/ntfy/ntfy_test.go @@ -2,7 +2,6 @@ package ntfy import ( "context" - "encoding/base64" "encoding/json" "io" "net/http" @@ -17,11 +16,11 @@ import ( func TestSend_happy_path(t *testing.T) { var ( - gotPath string - gotBody string - gotTitle string + gotPath string + gotBody string + gotTitle string gotPriority string - gotAuth string + gotAuth string ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path @@ -61,9 +60,14 @@ func TestSend_happy_path(t *testing.T) { } } -func TestSend_includes_data_header_when_data_set(t *testing.T) { - var gotData string +// Bugboard #126: ntfy does not relay X-* headers to subscribers, so Data must +// ride the body. With no explicit Body, a data-only push serializes Data as +// the JSON body — and must NOT set the dead X-Data header. +func TestSend_dataOnly_ridesBody_noXDataHeader(t *testing.T) { + var gotBody, gotData string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) gotData = r.Header.Get("X-Data") w.WriteHeader(http.StatusOK) })) @@ -72,39 +76,49 @@ func TestSend_includes_data_header_when_data_set(t *testing.T) { p := New(Config{BaseURL: srv.URL}, nil) err := p.Send(context.Background(), push.PushMessage{ DeviceToken: "topic", - Body: "x", Data: map[string]interface{}{"call_id": "abc-123"}, }) if err != nil { t.Fatalf("Send: %v", err) } - decoded, err := base64.StdEncoding.DecodeString(gotData) - if err != nil { - t.Fatalf("X-Data not valid base64: %v", err) + if gotData != "" { + t.Errorf("X-Data header must not be set (ntfy drops it); got %q", gotData) } var got map[string]interface{} - if err := json.Unmarshal(decoded, &got); err != nil { - t.Fatalf("X-Data not valid JSON: %v", err) + if err := json.Unmarshal([]byte(gotBody), &got); err != nil { + t.Fatalf("data-only body not valid JSON: %v (body=%q)", err, gotBody) } if got["call_id"] != "abc-123" { - t.Errorf("data round-trip failed: got %v", got) + t.Errorf("data did not ride the body: got %v", got) } } -func TestSend_no_data_no_data_header(t *testing.T) { - var gotData string +// An explicit Body wins — Data does NOT clobber a caller-supplied body (the +// caller owns the envelope; this is anchat's call-push pattern). +func TestSend_explicitBody_winsOverData(t *testing.T) { + var gotBody, gotData string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) gotData = r.Header.Get("X-Data") w.WriteHeader(http.StatusOK) })) defer srv.Close() p := New(Config{BaseURL: srv.URL}, nil) - if err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Body: "x"}); err != nil { - t.Fatal(err) + err := p.Send(context.Background(), push.PushMessage{ + DeviceToken: "topic", + Body: `{"type":"call.invite","callId":"c1"}`, + Data: map[string]interface{}{"ignored": "yes"}, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if gotBody != `{"type":"call.invite","callId":"c1"}` { + t.Errorf("explicit body not preserved; got %q", gotBody) } if gotData != "" { - t.Errorf("expected no X-Data header, got %q", gotData) + t.Errorf("X-Data header must not be set; got %q", gotData) } } diff --git a/sdk/package.json b/sdk/package.json index c1881b8..b6b3cee 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@debros/orama", - "version": "0.122.46", + "version": "0.122.47", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "type": "module", "main": "./dist/index.js",