wse-server 2.2.1__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {wse_server-2.2.1 → wse_server-2.3.0}/PKG-INFO +30 -1
  2. {wse_server-2.2.1 → wse_server-2.3.0}/README.md +29 -0
  3. {wse_server-2.2.1 → wse_server-2.3.0}/pyproject.toml +1 -1
  4. {wse_server-2.2.1 → wse_server-2.3.0}/rust/Cargo.lock +129 -1
  5. {wse_server-2.2.1 → wse_server-2.3.0}/rust/Cargo.toml +4 -1
  6. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/jwt.rs +164 -0
  7. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/cluster.rs +674 -115
  8. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/compression.rs +5 -0
  9. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/presence.rs +11 -6
  10. wse_server-2.3.0/rust/src/wse/recovery.rs +940 -0
  11. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/server.rs +708 -73
  12. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/__init__.py +1 -1
  13. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/_wse_accel.pyi +45 -1
  14. wse_server-2.2.1/rust/src/wse/recovery.rs +0 -520
  15. {wse_server-2.2.1 → wse_server-2.3.0}/LICENSE +0 -0
  16. {wse_server-2.2.1 → wse_server-2.3.0}/rust/.cargo/config.toml +0 -0
  17. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/lib.rs +0 -0
  18. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/filters.rs +0 -0
  19. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/mod.rs +0 -0
  20. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/queue.rs +0 -0
  21. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/rate_limiter.rs +0 -0
  22. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/reliability.rs +0 -0
  23. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/security.rs +0 -0
  24. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/sequencer.rs +0 -0
  25. {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/transformer.rs +0 -0
  26. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/__init__.py +0 -0
  27. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/filters.py +0 -0
  28. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/types.py +0 -0
  29. {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wse-server
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -70,6 +70,9 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
70
70
  | **Compression** | zlib for client-facing messages above threshold (default 1024 bytes) |
71
71
  | **MessagePack** | Opt-in binary transport via `?format=msgpack`, roughly 2x faster serialization, 30% smaller |
72
72
  | **Message signing** | Selective HMAC-SHA256 signing for critical operations, nonce-based replay prevention |
73
+ | **Queue groups** | Round-robin dispatch within named groups for load-balanced worker pools |
74
+ | **Topic ACL** | Per-connection allow/deny glob patterns for topic access control |
75
+ | **Graceful drain** | `drain()` sends Close frame to all clients, rejects new connections, notifies cluster peers |
73
76
 
74
77
  ### End-to-End Encryption
75
78
 
@@ -96,6 +99,7 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
96
99
  | **Circuit breaker** | 10 failures to open, 60s reset, 3 half-open probe calls |
97
100
  | **Dead letter queue** | 1000-entry ring buffer for failed cluster sends |
98
101
  | **Presence sync** | PresenceUpdate/PresenceFull frames, CRDT last-write-wins conflict resolution |
102
+ | **Topology API** | `cluster_info()` returns connected peer list with address, instance_id, status |
99
103
 
100
104
  ### Presence Tracking
101
105
 
@@ -295,6 +299,7 @@ server.broadcast(topic, text) # Fan-out to topic subscribers
295
299
  ```python
296
300
  server.subscribe_connection(conn_id, ["prices", "news"]) # Subscribe to topics
297
301
  server.subscribe_connection(conn_id, ["chat"], {"status": "online"}) # Subscribe with presence data
302
+ server.subscribe_connection(conn_id, ["tasks"], queue_group="workers") # Subscribe with queue group (round-robin)
298
303
  server.unsubscribe_connection(conn_id, ["news"]) # Unsubscribe from specific topics
299
304
  server.unsubscribe_connection(conn_id, None) # Unsubscribe from all topics
300
305
  server.get_topic_subscriber_count("prices") # Subscriber count for a topic
@@ -302,6 +307,26 @@ server.get_topic_subscriber_count("prices") # Subscrib
302
307
 
303
308
  Subscriptions are cleaned up automatically on disconnect. In cluster mode, interest changes are propagated to peers via SUB/UNSUB frames.
304
309
 
310
+ **Queue groups**: connections in the same `queue_group` receive messages round-robin instead of fanout. Normal subscribers (no queue group) still receive all messages. Useful for distributing work across a pool of consumers.
311
+
312
+ ### Topic ACL
313
+
314
+ Per-connection topic access control with glob pattern matching.
315
+
316
+ ```python
317
+ # Allow only "user:*" topics, deny everything else
318
+ server.set_topic_acl(conn_id, allow=["user:*"])
319
+
320
+ # Allow "data:*" but deny "data:internal:*"
321
+ server.set_topic_acl(conn_id, allow=["data:*"], deny=["data:internal:*"])
322
+
323
+ # Must be called before subscribe_connection
324
+ server.subscribe_connection(conn_id, ["data:prices"]) # allowed
325
+ server.subscribe_connection(conn_id, ["data:internal:audit"]) # denied
326
+ ```
327
+
328
+ Deny patterns take precedence over allow patterns. Supports `*` (any characters) and `?` (single character) wildcards. Applied at subscribe time.
329
+
305
330
  ### Presence Tracking
306
331
 
307
332
  Requires `presence_enabled=True` in the constructor.
@@ -362,6 +387,7 @@ server.connect_cluster(
362
387
 
363
388
  server.cluster_connected() # True if connected to at least one peer
364
389
  server.cluster_peers_count() # Number of active peer connections
390
+ server.cluster_info() # List of connected peers (address, instance_id, connected)
365
391
  ```
366
392
 
367
393
  Nodes form a full TCP mesh automatically. The cluster protocol uses a custom binary frame format with an 8-byte header, 12 message types, and capability negotiation during handshake. Features:
@@ -409,11 +435,14 @@ health = server.health_snapshot()
409
435
  server.get_connection_count() # Lock-free AtomicUsize read
410
436
  server.get_connections() # List all connection IDs (snapshot)
411
437
  server.disconnect(conn_id) # Force-disconnect a connection
438
+ server.drain(close_code=4300, close_reason="shutting down", timeout=10) # Graceful drain
412
439
  server.inbound_queue_depth() # Events waiting to be drained
413
440
  server.inbound_dropped_count() # Events dropped due to full queue
414
441
  server.get_cluster_dlq_entries() # Retrieve failed cluster messages from dead letter queue
415
442
  ```
416
443
 
444
+ `drain()` sends a WebSocket Close frame to all connected clients and rejects new connections. The drain wait runs as a separate task, so the command processor stays responsive. Use for zero-downtime deployments and rolling restarts.
445
+
417
446
  ---
418
447
 
419
448
  ## Security
@@ -29,6 +29,9 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
29
29
  | **Compression** | zlib for client-facing messages above threshold (default 1024 bytes) |
30
30
  | **MessagePack** | Opt-in binary transport via `?format=msgpack`, roughly 2x faster serialization, 30% smaller |
31
31
  | **Message signing** | Selective HMAC-SHA256 signing for critical operations, nonce-based replay prevention |
32
+ | **Queue groups** | Round-robin dispatch within named groups for load-balanced worker pools |
33
+ | **Topic ACL** | Per-connection allow/deny glob patterns for topic access control |
34
+ | **Graceful drain** | `drain()` sends Close frame to all clients, rejects new connections, notifies cluster peers |
32
35
 
33
36
  ### End-to-End Encryption
34
37
 
@@ -55,6 +58,7 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
55
58
  | **Circuit breaker** | 10 failures to open, 60s reset, 3 half-open probe calls |
56
59
  | **Dead letter queue** | 1000-entry ring buffer for failed cluster sends |
57
60
  | **Presence sync** | PresenceUpdate/PresenceFull frames, CRDT last-write-wins conflict resolution |
61
+ | **Topology API** | `cluster_info()` returns connected peer list with address, instance_id, status |
58
62
 
59
63
  ### Presence Tracking
60
64
 
@@ -254,6 +258,7 @@ server.broadcast(topic, text) # Fan-out to topic subscribers
254
258
  ```python
255
259
  server.subscribe_connection(conn_id, ["prices", "news"]) # Subscribe to topics
256
260
  server.subscribe_connection(conn_id, ["chat"], {"status": "online"}) # Subscribe with presence data
261
+ server.subscribe_connection(conn_id, ["tasks"], queue_group="workers") # Subscribe with queue group (round-robin)
257
262
  server.unsubscribe_connection(conn_id, ["news"]) # Unsubscribe from specific topics
258
263
  server.unsubscribe_connection(conn_id, None) # Unsubscribe from all topics
259
264
  server.get_topic_subscriber_count("prices") # Subscriber count for a topic
@@ -261,6 +266,26 @@ server.get_topic_subscriber_count("prices") # Subscrib
261
266
 
262
267
  Subscriptions are cleaned up automatically on disconnect. In cluster mode, interest changes are propagated to peers via SUB/UNSUB frames.
263
268
 
269
+ **Queue groups**: connections in the same `queue_group` receive messages round-robin instead of fanout. Normal subscribers (no queue group) still receive all messages. Useful for distributing work across a pool of consumers.
270
+
271
+ ### Topic ACL
272
+
273
+ Per-connection topic access control with glob pattern matching.
274
+
275
+ ```python
276
+ # Allow only "user:*" topics, deny everything else
277
+ server.set_topic_acl(conn_id, allow=["user:*"])
278
+
279
+ # Allow "data:*" but deny "data:internal:*"
280
+ server.set_topic_acl(conn_id, allow=["data:*"], deny=["data:internal:*"])
281
+
282
+ # Must be called before subscribe_connection
283
+ server.subscribe_connection(conn_id, ["data:prices"]) # allowed
284
+ server.subscribe_connection(conn_id, ["data:internal:audit"]) # denied
285
+ ```
286
+
287
+ Deny patterns take precedence over allow patterns. Supports `*` (any characters) and `?` (single character) wildcards. Applied at subscribe time.
288
+
264
289
  ### Presence Tracking
265
290
 
266
291
  Requires `presence_enabled=True` in the constructor.
@@ -321,6 +346,7 @@ server.connect_cluster(
321
346
 
322
347
  server.cluster_connected() # True if connected to at least one peer
323
348
  server.cluster_peers_count() # Number of active peer connections
349
+ server.cluster_info() # List of connected peers (address, instance_id, connected)
324
350
  ```
325
351
 
326
352
  Nodes form a full TCP mesh automatically. The cluster protocol uses a custom binary frame format with an 8-byte header, 12 message types, and capability negotiation during handshake. Features:
@@ -368,11 +394,14 @@ health = server.health_snapshot()
368
394
  server.get_connection_count() # Lock-free AtomicUsize read
369
395
  server.get_connections() # List all connection IDs (snapshot)
370
396
  server.disconnect(conn_id) # Force-disconnect a connection
397
+ server.drain(close_code=4300, close_reason="shutting down", timeout=10) # Graceful drain
371
398
  server.inbound_queue_depth() # Events waiting to be drained
372
399
  server.inbound_dropped_count() # Events dropped due to full queue
373
400
  server.get_cluster_dlq_entries() # Retrieve failed cluster messages from dead letter queue
374
401
  ```
375
402
 
403
+ `drain()` sends a WebSocket Close frame to all connected clients and rejects new connections. The drain wait runs as a separate task, so the command processor stays responsive. Use for zero-downtime deployments and rolling restarts.
404
+
376
405
  ---
377
406
 
378
407
  ## Security
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "wse-server"
7
- version = "2.2.1"
7
+ version = "2.3.0"
8
8
  description = "Rust-powered WebSocket engine for Python. Up to 5M fan-out del/s. Zero-GIL JWT."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -80,6 +80,15 @@ version = "1.0.102"
80
80
  source = "registry+https://github.com/rust-lang/crates.io-index"
81
81
  checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
82
82
 
83
+ [[package]]
84
+ name = "arc-swap"
85
+ version = "1.8.2"
86
+ source = "registry+https://github.com/rust-lang/crates.io-index"
87
+ checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
88
+ dependencies = [
89
+ "rustversion",
90
+ ]
91
+
83
92
  [[package]]
84
93
  name = "autocfg"
85
94
  version = "1.5.0"
@@ -799,6 +808,15 @@ version = "0.4.29"
799
808
  source = "registry+https://github.com/rust-lang/crates.io-index"
800
809
  checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
801
810
 
811
+ [[package]]
812
+ name = "matchers"
813
+ version = "0.2.0"
814
+ source = "registry+https://github.com/rust-lang/crates.io-index"
815
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
816
+ dependencies = [
817
+ "regex-automata",
818
+ ]
819
+
802
820
  [[package]]
803
821
  name = "memchr"
804
822
  version = "2.8.0"
@@ -835,6 +853,15 @@ dependencies = [
835
853
  "windows-sys 0.61.2",
836
854
  ]
837
855
 
856
+ [[package]]
857
+ name = "nu-ansi-term"
858
+ version = "0.50.3"
859
+ source = "registry+https://github.com/rust-lang/crates.io-index"
860
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
861
+ dependencies = [
862
+ "windows-sys 0.61.2",
863
+ ]
864
+
838
865
  [[package]]
839
866
  name = "num-bigint"
840
867
  version = "0.4.6"
@@ -1483,6 +1510,15 @@ dependencies = [
1483
1510
  "digest",
1484
1511
  ]
1485
1512
 
1513
+ [[package]]
1514
+ name = "sharded-slab"
1515
+ version = "0.1.7"
1516
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1517
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1518
+ dependencies = [
1519
+ "lazy_static",
1520
+ ]
1521
+
1486
1522
  [[package]]
1487
1523
  name = "shlex"
1488
1524
  version = "1.3.0"
@@ -1598,6 +1634,15 @@ dependencies = [
1598
1634
  "syn",
1599
1635
  ]
1600
1636
 
1637
+ [[package]]
1638
+ name = "thread_local"
1639
+ version = "1.1.9"
1640
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1641
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1642
+ dependencies = [
1643
+ "cfg-if",
1644
+ ]
1645
+
1601
1646
  [[package]]
1602
1647
  name = "time"
1603
1648
  version = "0.3.47"
@@ -1691,6 +1736,80 @@ dependencies = [
1691
1736
  "tokio",
1692
1737
  ]
1693
1738
 
1739
+ [[package]]
1740
+ name = "tracing"
1741
+ version = "0.1.44"
1742
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1743
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1744
+ dependencies = [
1745
+ "pin-project-lite",
1746
+ "tracing-attributes",
1747
+ "tracing-core",
1748
+ ]
1749
+
1750
+ [[package]]
1751
+ name = "tracing-attributes"
1752
+ version = "0.1.31"
1753
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1754
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1755
+ dependencies = [
1756
+ "proc-macro2",
1757
+ "quote",
1758
+ "syn",
1759
+ ]
1760
+
1761
+ [[package]]
1762
+ name = "tracing-core"
1763
+ version = "0.1.36"
1764
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1765
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1766
+ dependencies = [
1767
+ "once_cell",
1768
+ "valuable",
1769
+ ]
1770
+
1771
+ [[package]]
1772
+ name = "tracing-log"
1773
+ version = "0.2.0"
1774
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1775
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1776
+ dependencies = [
1777
+ "log",
1778
+ "once_cell",
1779
+ "tracing-core",
1780
+ ]
1781
+
1782
+ [[package]]
1783
+ name = "tracing-serde"
1784
+ version = "0.2.0"
1785
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1786
+ checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
1787
+ dependencies = [
1788
+ "serde",
1789
+ "tracing-core",
1790
+ ]
1791
+
1792
+ [[package]]
1793
+ name = "tracing-subscriber"
1794
+ version = "0.3.22"
1795
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1796
+ checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
1797
+ dependencies = [
1798
+ "matchers",
1799
+ "nu-ansi-term",
1800
+ "once_cell",
1801
+ "regex-automata",
1802
+ "serde",
1803
+ "serde_json",
1804
+ "sharded-slab",
1805
+ "smallvec",
1806
+ "thread_local",
1807
+ "tracing",
1808
+ "tracing-core",
1809
+ "tracing-log",
1810
+ "tracing-serde",
1811
+ ]
1812
+
1694
1813
  [[package]]
1695
1814
  name = "tungstenite"
1696
1815
  version = "0.28.0"
@@ -1759,6 +1878,12 @@ dependencies = [
1759
1878
  "wasm-bindgen",
1760
1879
  ]
1761
1880
 
1881
+ [[package]]
1882
+ name = "valuable"
1883
+ version = "0.1.1"
1884
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1885
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1886
+
1762
1887
  [[package]]
1763
1888
  name = "version_check"
1764
1889
  version = "0.9.5"
@@ -2099,10 +2224,11 @@ dependencies = [
2099
2224
 
2100
2225
  [[package]]
2101
2226
  name = "wse-accel"
2102
- version = "2.2.1"
2227
+ version = "2.3.0"
2103
2228
  dependencies = [
2104
2229
  "aes-gcm",
2105
2230
  "ahash",
2231
+ "arc-swap",
2106
2232
  "base64",
2107
2233
  "bytes",
2108
2234
  "chrono",
@@ -2132,6 +2258,8 @@ dependencies = [
2132
2258
  "tokio-rustls",
2133
2259
  "tokio-tungstenite",
2134
2260
  "tokio-util",
2261
+ "tracing",
2262
+ "tracing-subscriber",
2135
2263
  "uuid",
2136
2264
  "zstd",
2137
2265
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "wse-accel"
3
- version = "2.2.1"
3
+ version = "2.3.0"
4
4
  edition = "2024"
5
5
 
6
6
  [lib]
@@ -40,6 +40,9 @@ rustls = { version = "0.23", default-features = true }
40
40
  rustls-pki-types = "1"
41
41
  zstd = "0.13"
42
42
  parking_lot = "0.12"
43
+ tracing = "0.1"
44
+ tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
45
+ arc-swap = "1"
43
46
 
44
47
  [dev-dependencies]
45
48
  rcgen = "0.13"
@@ -502,6 +502,82 @@ fn json_value_to_py(py: Python<'_>, val: &serde_json::Value) -> Py<PyAny> {
502
502
  }
503
503
  }
504
504
 
505
+ // ---------------------------------------------------------------------------
506
+ // TopicAcl -- per-connection topic authorization from JWT claims
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /// Topic-level access control list extracted from JWT `wse_topics` claim.
510
+ ///
511
+ /// Format in JWT: `{"wse_topics": {"allow": ["chat.*"], "deny": ["admin.*"]}}`
512
+ ///
513
+ /// Rules:
514
+ /// - Deny takes precedence over allow
515
+ /// - Empty allow list = allow all (backward compatible)
516
+ /// - Patterns use glob matching (*, ?)
517
+ #[derive(Debug, Clone)]
518
+ pub struct TopicAcl {
519
+ pub allow: Vec<String>,
520
+ pub deny: Vec<String>,
521
+ }
522
+
523
+ impl TopicAcl {
524
+ /// Check if a topic is allowed by this ACL.
525
+ ///
526
+ /// Deny rules take precedence. Empty allow = allow all.
527
+ pub fn is_allowed(&self, topic: &str) -> bool {
528
+ // Check deny first (deny takes precedence)
529
+ for pattern in &self.deny {
530
+ if crate::wse::server::glob_match(pattern, topic) {
531
+ return false;
532
+ }
533
+ }
534
+
535
+ // Empty allow = allow all
536
+ if self.allow.is_empty() {
537
+ return true;
538
+ }
539
+
540
+ // Check allow patterns
541
+ for pattern in &self.allow {
542
+ if crate::wse::server::glob_match(pattern, topic) {
543
+ return true;
544
+ }
545
+ }
546
+
547
+ false
548
+ }
549
+ }
550
+
551
+ /// Extract optional topic ACL from JWT claims.
552
+ ///
553
+ /// Returns `None` if no `wse_topics` claim is present (backward compatible).
554
+ pub fn extract_topic_acl(claims: &serde_json::Value) -> Option<TopicAcl> {
555
+ let topics = claims.get("wse_topics")?;
556
+ let obj = topics.as_object()?;
557
+
558
+ let allow = obj
559
+ .get("allow")
560
+ .and_then(|v| v.as_array())
561
+ .map(|arr| {
562
+ arr.iter()
563
+ .filter_map(|v| v.as_str().map(String::from))
564
+ .collect()
565
+ })
566
+ .unwrap_or_default();
567
+
568
+ let deny = obj
569
+ .get("deny")
570
+ .and_then(|v| v.as_array())
571
+ .map(|arr| {
572
+ arr.iter()
573
+ .filter_map(|v| v.as_str().map(String::from))
574
+ .collect()
575
+ })
576
+ .unwrap_or_default();
577
+
578
+ Some(TopicAcl { allow, deny })
579
+ }
580
+
505
581
  // ---------------------------------------------------------------------------
506
582
  // Rust-level unit tests
507
583
  // ---------------------------------------------------------------------------
@@ -1222,4 +1298,92 @@ mod tests {
1222
1298
  let result = jwt_decode(&token, &config);
1223
1299
  assert!(result.is_err());
1224
1300
  }
1301
+
1302
+ // --- TopicAcl tests ---
1303
+
1304
+ #[test]
1305
+ fn test_topic_acl_allow_only() {
1306
+ let acl = TopicAcl {
1307
+ allow: vec!["chat.*".to_string(), "events".to_string()],
1308
+ deny: vec![],
1309
+ };
1310
+ assert!(acl.is_allowed("chat.general"));
1311
+ assert!(acl.is_allowed("chat.private"));
1312
+ assert!(acl.is_allowed("events"));
1313
+ assert!(!acl.is_allowed("admin.settings"));
1314
+ assert!(!acl.is_allowed("other.topic"));
1315
+ }
1316
+
1317
+ #[test]
1318
+ fn test_topic_acl_deny_precedence() {
1319
+ let acl = TopicAcl {
1320
+ allow: vec!["chat.*".to_string()],
1321
+ deny: vec!["chat.secret".to_string()],
1322
+ };
1323
+ assert!(acl.is_allowed("chat.general"));
1324
+ assert!(!acl.is_allowed("chat.secret"));
1325
+ }
1326
+
1327
+ #[test]
1328
+ fn test_topic_acl_empty_allow() {
1329
+ // Empty allow = allow all (only deny filters)
1330
+ let acl = TopicAcl {
1331
+ allow: vec![],
1332
+ deny: vec!["admin.*".to_string()],
1333
+ };
1334
+ assert!(acl.is_allowed("chat.general"));
1335
+ assert!(acl.is_allowed("events"));
1336
+ assert!(!acl.is_allowed("admin.settings"));
1337
+ }
1338
+
1339
+ #[test]
1340
+ fn test_topic_acl_glob_matching() {
1341
+ let acl = TopicAcl {
1342
+ allow: vec!["user.*.messages".to_string(), "broadcast".to_string()],
1343
+ deny: vec![],
1344
+ };
1345
+ assert!(acl.is_allowed("user.123.messages"));
1346
+ assert!(acl.is_allowed("user.abc.messages"));
1347
+ assert!(acl.is_allowed("broadcast"));
1348
+ assert!(!acl.is_allowed("user.123.settings"));
1349
+ }
1350
+
1351
+ #[test]
1352
+ fn test_extract_topic_acl_present() {
1353
+ let claims = serde_json::json!({
1354
+ "sub": "user1",
1355
+ "wse_topics": {
1356
+ "allow": ["chat.*", "events"],
1357
+ "deny": ["admin.*"]
1358
+ }
1359
+ });
1360
+ let acl = extract_topic_acl(&claims).unwrap();
1361
+ assert_eq!(acl.allow, vec!["chat.*", "events"]);
1362
+ assert_eq!(acl.deny, vec!["admin.*"]);
1363
+ }
1364
+
1365
+ #[test]
1366
+ fn test_extract_topic_acl_missing() {
1367
+ let claims = serde_json::json!({"sub": "user1"});
1368
+ assert!(extract_topic_acl(&claims).is_none());
1369
+ }
1370
+
1371
+ #[test]
1372
+ fn test_extract_topic_acl_partial() {
1373
+ // Only allow, no deny
1374
+ let claims = serde_json::json!({
1375
+ "sub": "user1",
1376
+ "wse_topics": {"allow": ["chat.*"]}
1377
+ });
1378
+ let acl = extract_topic_acl(&claims).unwrap();
1379
+ assert_eq!(acl.allow, vec!["chat.*"]);
1380
+ assert!(acl.deny.is_empty());
1381
+ }
1382
+
1383
+ #[test]
1384
+ fn test_no_acl_backward_compat() {
1385
+ // No wse_topics claim = no ACL = extract returns None
1386
+ let claims = serde_json::json!({"sub": "user1", "iss": "myapp"});
1387
+ assert!(extract_topic_acl(&claims).is_none());
1388
+ }
1225
1389
  }