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
This commit is contained in:
anonpenguin23 2026-06-11 11:43:56 +03:00
parent f4c58db710
commit cd8c717363
8 changed files with 261 additions and 86 deletions

View File

@ -1 +1 @@
0.122.46
0.122.47

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -1,18 +1,28 @@
// Package ntfy implements a push.PushProvider backed by an ntfy server.
//
// ntfy delivers notifications via plain HTTP POST to <baseURL>/<topic>.
// 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)
}

View File

@ -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)
}
}

View File

@ -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",