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) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
logger.ComponentError(logging.ComponentTURN, "Config file not found", logger.ComponentError(logging.ComponentTURN, "Config file not found",
@ -60,26 +47,13 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
os.Exit(1) os.Exit(1)
} }
var y yamlCfg cfg, err := decodeTURNConfig(data)
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil { if err != nil {
logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err)) logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err))
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err) fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
os.Exit(1) 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 { if errs := cfg.Validate(); len(errs) > 0 {
fmt.Fprintf(os.Stderr, "\nTURN configuration errors (%d):\n", len(errs)) fmt.Fprintf(os.Stderr, "\nTURN configuration errors (%d):\n", len(errs))
for _, e := range errs { for _, e := range errs {
@ -98,3 +72,50 @@ func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
return cfg 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) { if p.kind != KindVoIP && !hasVisibleContent(msg) {
return push.ErrEmptyContent return push.ErrEmptyContent
} }
payload, err := buildAPSPayload(msg) payload, err := buildAPSPayload(msg, p.kind)
if err != nil { if err != nil {
return fmt.Errorf("apns: build payload: %w", err) return fmt.Errorf("apns: build payload: %w", err)
} }
@ -281,12 +281,25 @@ func tokenPrefix(token string) string {
return token[:8] + "..." return token[:8] + "..."
} }
// buildAPSPayload assembles the APNs JSON payload from a generic // buildAPSPayload assembles the APNs JSON payload from a generic PushMessage.
// PushMessage. The `aps` dictionary is the Apple-required wrapper; // The `aps` dictionary is the Apple-required wrapper; custom `Data` placement
// custom fields (`data`) go alongside at the top level. // 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 // 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{} alert := map[string]string{}
if msg.Title != "" { if msg.Title != "" {
alert["title"] = msg.Title alert["title"] = msg.Title
@ -338,13 +351,28 @@ func buildAPSPayload(msg push.PushMessage) ([]byte, error) {
} }
} }
root := map[string]interface{}{"aps": aps} 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 { 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" { if k == "aps" || k == "content_available" {
continue continue
} }
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 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) return json.Marshal(root)
} }

View File

@ -155,7 +155,7 @@ func TestValidator_RedactNeverEchoesP8Key(t *testing.T) {
func TestBuildAPSPayload_basicAlert(t *testing.T) { func TestBuildAPSPayload_basicAlert(t *testing.T) {
msg := push.PushMessage{Title: "hi", Body: "from orama"} msg := push.PushMessage{Title: "hi", Body: "from orama"}
raw, err := buildAPSPayload(msg) raw, err := buildAPSPayload(msg, KindAlert)
if err != nil { if err != nil {
t.Fatalf("build: %v", err) 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{ msg := push.PushMessage{
Title: "x", Title: "x",
Body: "y", Body: "y",
Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"}, Data: map[string]interface{}{"thread": "abc", "deeplink": "anchat://room/42"},
} }
raw, _ := buildAPSPayload(msg) raw, _ := buildAPSPayload(msg, KindAlert)
var out map[string]interface{} 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 { if _, hasAPS := out["aps"]; !hasAPS {
t.Error("payload missing aps") t.Error("payload missing aps")
} }
if out["thread"] != "abc" { // Must NOT be at the top level (expo would ignore it there).
t.Errorf("data.thread missing; got %v", out) 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" { body, ok := out["body"].(map[string]interface{})
t.Errorf("data.deeplink missing; got %v", out) 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", Title: "x",
Data: map[string]interface{}{"aps": "evil"}, Data: map[string]interface{}{"aps": "evil"},
} }
raw, _ := buildAPSPayload(msg) raw, _ := buildAPSPayload(msg, KindAlert)
var out map[string]interface{} var out map[string]interface{}
_ = json.Unmarshal(raw, &out) _ = json.Unmarshal(raw, &out)
apsField, ok := out["aps"] apsField, ok := out["aps"]
@ -215,7 +250,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) {
msg := push.PushMessage{ msg := push.PushMessage{
Title: "x", Badge: 3, Sound: "ding.caf", Title: "x", Badge: 3, Sound: "ding.caf",
} }
raw, _ := buildAPSPayload(msg) raw, _ := buildAPSPayload(msg, KindAlert)
if !strings.Contains(string(raw), `"badge":3`) { if !strings.Contains(string(raw), `"badge":3`) {
t.Errorf("badge not in payload: %s", raw) t.Errorf("badge not in payload: %s", raw)
} }
@ -226,7 +261,7 @@ func TestBuildAPSPayload_badgeAndSound(t *testing.T) {
func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) { func TestBuildAPSPayload_channelMapsToThreadID(t *testing.T) {
msg := push.PushMessage{Title: "x", Channel: "messages"} msg := push.PushMessage{Title: "x", Channel: "messages"}
raw, _ := buildAPSPayload(msg) raw, _ := buildAPSPayload(msg, KindAlert)
if !strings.Contains(string(raw), `"thread-id":"messages"`) { if !strings.Contains(string(raw), `"thread-id":"messages"`) {
t.Errorf("channel not mapped to thread-id: %s", raw) 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. // Package ntfy implements a push.PushProvider backed by an ntfy server.
// //
// ntfy delivers notifications via plain HTTP POST to <baseURL>/<topic>. // ntfy delivers notifications via plain HTTP POST to <baseURL>/<topic>.
// We map PushMessage fields to ntfy headers: // We map PushMessage fields to the ntfy publish surface:
// - Title -> "Title" // - Title -> "Title" header
// - Priority -> "Priority" // - Priority -> "Priority" header
// - Channel -> "Tags" // - Channel -> "Tags" header
// - Data -> base64-encoded JSON in "X-Data" // - 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 package ntfy
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -79,7 +89,20 @@ func (p *Provider) Send(ctx context.Context, msg push.PushMessage) error {
return err 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 { if err != nil {
return fmt.Errorf("ntfy: build request: %w", err) 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. // ntfy uses "Tags" for both visual emoji and operator-defined tags.
req.Header.Set("Tags", msg.Channel) req.Header.Set("Tags", msg.Channel)
} }
if msg.Badge > 0 { // NOTE: Badge and arbitrary Data are intentionally NOT sent as custom
req.Header.Set("X-Badge", fmt.Sprintf("%d", msg.Badge)) // headers — ntfy does not relay `X-*` headers to subscribers (#126), so
} // doing so silently drops them. Data rides the body (above); a badge
if len(msg.Data) > 0 { // count, if needed, must be encoded into the body by the caller.
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))
}
if p.authToken != "" { if p.authToken != "" {
req.Header.Set("Authorization", "Bearer "+p.authToken) req.Header.Set("Authorization", "Bearer "+p.authToken)
} }

View File

@ -2,7 +2,6 @@ package ntfy
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
@ -61,9 +60,14 @@ func TestSend_happy_path(t *testing.T) {
} }
} }
func TestSend_includes_data_header_when_data_set(t *testing.T) { // Bugboard #126: ntfy does not relay X-* headers to subscribers, so Data must
var gotData string // 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) { 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") gotData = r.Header.Get("X-Data")
w.WriteHeader(http.StatusOK) 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) p := New(Config{BaseURL: srv.URL}, nil)
err := p.Send(context.Background(), push.PushMessage{ err := p.Send(context.Background(), push.PushMessage{
DeviceToken: "topic", DeviceToken: "topic",
Body: "x",
Data: map[string]interface{}{"call_id": "abc-123"}, Data: map[string]interface{}{"call_id": "abc-123"},
}) })
if err != nil { if err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
decoded, err := base64.StdEncoding.DecodeString(gotData) if gotData != "" {
if err != nil { t.Errorf("X-Data header must not be set (ntfy drops it); got %q", gotData)
t.Fatalf("X-Data not valid base64: %v", err)
} }
var got map[string]interface{} var got map[string]interface{}
if err := json.Unmarshal(decoded, &got); err != nil { if err := json.Unmarshal([]byte(gotBody), &got); err != nil {
t.Fatalf("X-Data not valid JSON: %v", err) t.Fatalf("data-only body not valid JSON: %v (body=%q)", err, gotBody)
} }
if got["call_id"] != "abc-123" { 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) { // An explicit Body wins — Data does NOT clobber a caller-supplied body (the
var gotData string // 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) { 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") gotData = r.Header.Get("X-Data")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
defer srv.Close() defer srv.Close()
p := New(Config{BaseURL: srv.URL}, nil) p := New(Config{BaseURL: srv.URL}, nil)
if err := p.Send(context.Background(), push.PushMessage{DeviceToken: "t", Body: "x"}); err != nil { err := p.Send(context.Background(), push.PushMessage{
t.Fatal(err) 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 != "" { 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", "name": "@debros/orama",
"version": "0.122.46", "version": "0.122.47",
"description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",