messagefoundry 0.1.0__py3-none-any.whl

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 (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,552 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Synchronous API client for the console.
4
+
5
+ A small typed wrapper over the localhost REST API. It is deliberately **synchronous** —
6
+ Qt has its own event loop and localhost calls are sub-millisecond, so blocking briefly on
7
+ the GUI thread is simpler and safe enough for Phase 1 (a short timeout guards against a
8
+ hung/dead server). It returns the API's pydantic models so callers get typed, validated
9
+ data rather than raw dicts.
10
+
11
+ Kept free of any Qt import so it can be unit-tested on its own against a real server.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Callable
18
+ from json import JSONDecodeError
19
+ from types import TracebackType
20
+ from typing import TypeVar
21
+ from urllib.parse import urlsplit
22
+
23
+ import httpx
24
+ from pydantic import BaseModel, ValidationError
25
+
26
+ from messagefoundry.api.auth_models import (
27
+ AdGroupMap,
28
+ AdGroupMapEntry,
29
+ AuditList,
30
+ ChannelScope,
31
+ CurrentUser,
32
+ LoginResponse,
33
+ MfaConfirmResponse,
34
+ MfaEnrollResponse,
35
+ MfaStatusResponse,
36
+ ProvidersInfo,
37
+ RoleInfo,
38
+ SessionInfo,
39
+ SessionList,
40
+ SimpleMessage,
41
+ UserSummary,
42
+ )
43
+ from messagefoundry.api.models import (
44
+ ChannelInfo,
45
+ ClusterNodeList,
46
+ ClusterStatus,
47
+ ConnectionRow,
48
+ DeadLetterList,
49
+ DeadLetterReplayResult,
50
+ Health,
51
+ IntegrityResult,
52
+ MessageDetail,
53
+ MessageList,
54
+ PurgeResult,
55
+ ReloadResult,
56
+ ReplayResult,
57
+ StatsResponse,
58
+ SystemStatus,
59
+ )
60
+
61
+ __all__ = ["EngineClient", "ApiError"]
62
+
63
+ _log = logging.getLogger(__name__)
64
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
65
+
66
+
67
+ class ApiError(RuntimeError):
68
+ """An API call failed (transport error, a non-2xx response, or an undecodable 2xx body)."""
69
+
70
+ def __init__(self, message: str, *, status: int | None = None) -> None:
71
+ super().__init__(message)
72
+ self.status = status
73
+
74
+
75
+ _Model = TypeVar("_Model", bound=BaseModel)
76
+
77
+
78
+ def _decode(response: httpx.Response, model: type[_Model]) -> _Model:
79
+ """Validate a 2xx JSON body into ``model``, mapping schema/JSON errors to :class:`ApiError`.
80
+
81
+ Preserves the client's contract that every call raises only ``ApiError``: a malformed or
82
+ schema-mismatched success body (e.g. an engine version skew) would otherwise raise pydantic's
83
+ ``ValidationError`` straight out of a Qt slot into the event loop (H2/L2)."""
84
+ try:
85
+ return model.model_validate(response.json())
86
+ except (ValidationError, JSONDecodeError) as exc:
87
+ raise ApiError(f"invalid response from engine: {exc}") from exc
88
+
89
+
90
+ def _decode_list(response: httpx.Response, model: type[_Model]) -> list[_Model]:
91
+ """List form of :func:`_decode` (the body must be a JSON array of ``model`` objects)."""
92
+ try:
93
+ return [model.model_validate(item) for item in response.json()]
94
+ except (ValidationError, JSONDecodeError, TypeError) as exc:
95
+ raise ApiError(f"invalid response from engine: {exc}") from exc
96
+
97
+
98
+ def _assert_safe_transport(base_url: str, *, allow_insecure: bool) -> None:
99
+ """Refuse plaintext ``http`` to a non-loopback host (CONSOLE-3).
100
+
101
+ There is no transport TLS yet, so a remote ``http://`` URL would put the bearer token and PHI on
102
+ the wire in cleartext. Loopback http and any https are fine; a non-loopback http URL requires an
103
+ explicit ``allow_insecure`` opt-in (trusted-network dev only), which is then loudly warned."""
104
+ parts = urlsplit(base_url)
105
+ if parts.scheme == "https":
106
+ return
107
+ host = (parts.hostname or "").lower()
108
+ if host in _LOOPBACK_HOSTS or host == "":
109
+ return
110
+ if allow_insecure:
111
+ _log.warning(
112
+ "sending credentials over plaintext http to non-loopback host %r (allow_insecure)", host
113
+ )
114
+ return
115
+ raise ApiError(
116
+ f"refusing to use plaintext http to non-loopback host {host!r}: the bearer token and PHI "
117
+ "would cross the network in cleartext. Use an https URL, or pass --insecure for a "
118
+ "trusted-network dev setup."
119
+ )
120
+
121
+
122
+ class EngineClient:
123
+ """Blocking client for the MessageFoundry localhost API.
124
+
125
+ Use as a context manager (or call :meth:`close`) to release the connection pool.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ base_url: str = "http://127.0.0.1:8765",
131
+ *,
132
+ timeout: float = 5.0,
133
+ allow_insecure: bool = False,
134
+ tls_client_cert: str | None = None,
135
+ tls_client_key: str | None = None,
136
+ ) -> None:
137
+ self.base_url = base_url.rstrip("/")
138
+ self._timeout = timeout
139
+ self._allow_insecure = allow_insecure
140
+ _assert_safe_transport(self.base_url, allow_insecure=allow_insecure)
141
+ # Optional client certificate for mutual TLS (ASVS 12.3.5): when the engine API requires a
142
+ # client cert (api.tls_client_ca_file → CERT_REQUIRED), the console presents this PEM cert
143
+ # (plus a separate key file when the key isn't bundled in the cert PEM) so it authenticates to
144
+ # the API by certificate, not only the bearer token. Off by default; meaningful only over https
145
+ # (the server, not the console, decides whether the cert is required).
146
+ cert: str | tuple[str, str] | None = None
147
+ if tls_client_cert is not None:
148
+ cert = (tls_client_cert, tls_client_key) if tls_client_key else tls_client_cert
149
+ self._http = httpx.Client(base_url=self.base_url, timeout=timeout, cert=cert)
150
+ self._token: str | None = None
151
+ self._user: CurrentUser | None = None
152
+ #: Invoked when the engine demands step-up re-verification (403 + X-Step-Up-Required); the GUI
153
+ #: prompts, calls reauth(), and returns True iff re-verified — then the request is retried.
154
+ self._step_up_handler: Callable[[], bool] | None = None
155
+ #: Invoked when the engine demands a second factor (403 + X-MFA-Required, WP-14); the GUI
156
+ #: prompts for a TOTP / recovery code, calls verify_mfa(), and returns True iff verified.
157
+ self._mfa_handler: Callable[[], bool] | None = None
158
+
159
+ def __enter__(self) -> "EngineClient":
160
+ return self
161
+
162
+ def __exit__(
163
+ self,
164
+ exc_type: type[BaseException] | None,
165
+ exc: BaseException | None,
166
+ tb: TracebackType | None,
167
+ ) -> None:
168
+ self.close()
169
+
170
+ def close(self) -> None:
171
+ self._http.close()
172
+
173
+ def for_polling(self) -> "EngineClient":
174
+ """A second client dedicated to **background (off-thread) reads** — the nav health poll, the
175
+ Engine Status refresh, and the per-page auto-refresh.
176
+
177
+ It shares this client's bearer token but has its **own** ``httpx.Client`` connection pool and
178
+ **no step-up/MFA handlers**, so background reader threads never contend on the main-thread
179
+ client's pool or its mutable auth state. That separation is what makes the console
180
+ concurrency-safe: the handler-bearing, token-mutating primary client stays **main-thread
181
+ only** (it serves the modal sign-in/step-up/MFA flows and user actions), while this read-only
182
+ client is the only one shared across worker threads — and sharing *it* is safe because its
183
+ token is never mutated, its 403→prompt retry branches are inert (no handlers), and
184
+ ``httpx.Client`` is itself thread-safe for concurrent requests.
185
+
186
+ The token is copied at creation. A mid-session credential change relaunches the console
187
+ (sign-out/expiry quits the app), so this snapshot can't drift out from under a live window.
188
+ """
189
+ poll = EngineClient(
190
+ self.base_url, timeout=self._timeout, allow_insecure=self._allow_insecure
191
+ )
192
+ poll._token = self._token
193
+ poll._user = self._user
194
+ return poll
195
+
196
+ # --- requests ------------------------------------------------------------
197
+
198
+ def _get(self, path: str, **params: object) -> httpx.Response:
199
+ return self._request("GET", path, params={k: v for k, v in params.items() if v is not None})
200
+
201
+ def _request(
202
+ self,
203
+ method: str,
204
+ path: str,
205
+ *,
206
+ _allow_step_up: bool = True,
207
+ _allow_mfa: bool = True,
208
+ **kw: object,
209
+ ) -> httpx.Response:
210
+ headers = {"Authorization": f"Bearer {self._token}"} if self._token else None
211
+ try:
212
+ response = self._http.request(method, path, headers=headers, **kw) # type: ignore[arg-type]
213
+ except httpx.HTTPError as exc:
214
+ raise ApiError(f"could not reach engine at {self.base_url}: {exc}") from exc
215
+ # Second factor (WP-14, ASVS 6.3.3): the engine refuses a sensitive op with 403 +
216
+ # X-MFA-Required when this session hasn't satisfied MFA. Prompt for a code (the handler calls
217
+ # verify_mfa()) and retry once — transparently, like step-up. Checked first because the engine
218
+ # gates MFA before step-up.
219
+ if (
220
+ _allow_mfa
221
+ and response.status_code == 403
222
+ and response.headers.get("X-MFA-Required")
223
+ and self._mfa_handler is not None
224
+ and self._mfa_handler()
225
+ ):
226
+ return self._request(
227
+ method, path, _allow_step_up=_allow_step_up, _allow_mfa=False, **kw
228
+ )
229
+ # Step-up re-verification (ASVS 7.5.3): the engine refuses a sensitive op with 403 +
230
+ # X-Step-Up-Required when this session hasn't re-proved its credential recently. Prompt the
231
+ # user (the handler re-authenticates via reauth()) and retry once, so the action goes through
232
+ # transparently instead of surfacing a raw 403.
233
+ if (
234
+ _allow_step_up
235
+ and response.status_code == 403
236
+ and response.headers.get("X-Step-Up-Required")
237
+ and self._step_up_handler is not None
238
+ and self._step_up_handler()
239
+ ):
240
+ return self._request(method, path, _allow_step_up=False, _allow_mfa=_allow_mfa, **kw)
241
+ if response.status_code >= 400:
242
+ raise ApiError(_error_detail(response), status=response.status_code)
243
+ return response
244
+
245
+ def set_step_up_handler(self, handler: Callable[[], bool] | None) -> None:
246
+ """Register the callback invoked when the engine demands step-up re-verification (403 +
247
+ ``X-Step-Up-Required``). It must prompt the user, call :meth:`reauth`, and return ``True`` iff
248
+ re-verified — the original request is then retried once. Runs on the calling thread (the
249
+ console's sensitive actions run on the Qt main thread, so a modal dialog is safe)."""
250
+ self._step_up_handler = handler
251
+
252
+ def reauth(self, password: str) -> None:
253
+ """Step-up re-verification (ASVS 7.5.3): re-prove the current credential to refresh this
254
+ session's step-up window. Raises :class:`ApiError` (status 403) on a wrong password. Does not
255
+ itself trigger the step-up handler (``/me/reauth`` is not a step-up-gated route)."""
256
+ self._request("POST", "/me/reauth", json={"password": password}, _allow_step_up=False)
257
+
258
+ def set_mfa_handler(self, handler: Callable[[], bool] | None) -> None:
259
+ """Register the callback invoked when the engine demands a second factor (403 +
260
+ ``X-MFA-Required``, WP-14). It must prompt for a TOTP / recovery code, call :meth:`verify_mfa`,
261
+ and return ``True`` iff verified — the original request is then retried once."""
262
+ self._mfa_handler = handler
263
+
264
+ # --- MFA (WP-14, ASVS 6.3.3) ---------------------------------------------
265
+
266
+ def mfa_status(self) -> MfaStatusResponse:
267
+ """The signed-in user's MFA posture (enabled, enrolled-at, recovery codes left, required)."""
268
+ return _decode(self._get("/me/mfa"), MfaStatusResponse)
269
+
270
+ def enroll_mfa(self) -> MfaEnrollResponse:
271
+ """Begin TOTP enrollment: stage a secret and return it + the ``otpauth://`` URI. Step-up gated,
272
+ so the step-up handler may prompt for the password before this returns."""
273
+ return _decode(self._request("POST", "/me/mfa/enroll"), MfaEnrollResponse)
274
+
275
+ def confirm_mfa(self, code: str) -> list[str]:
276
+ """Confirm enrollment with a live TOTP code; activates MFA and returns the one-time recovery
277
+ codes (shown **once**). Raises :class:`ApiError` (400) on a wrong code."""
278
+ return _decode(
279
+ self._request("POST", "/me/mfa/confirm", json={"code": code}), MfaConfirmResponse
280
+ ).recovery_codes
281
+
282
+ def verify_mfa(self, code: str) -> None:
283
+ """Satisfy the current session's second factor with a TOTP or single-use recovery code. Raises
284
+ :class:`ApiError` (401) on a wrong code. Does not itself trigger the MFA handler."""
285
+ self._request("POST", "/auth/mfa-verify", json={"code": code}, _allow_mfa=False)
286
+
287
+ def disable_mfa(self) -> None:
288
+ """Turn off the signed-in user's TOTP MFA (step-up gated)."""
289
+ self._request("DELETE", "/me/mfa")
290
+
291
+ def reset_user_mfa(self, user_id: str) -> None:
292
+ """Admin: clear a user's MFA enrollment and revoke their sessions (step-up gated)."""
293
+ self._request("POST", f"/users/{user_id}/reset-mfa")
294
+
295
+ # --- endpoints -----------------------------------------------------------
296
+
297
+ def health(self) -> Health:
298
+ return _decode(self._get("/health"), Health)
299
+
300
+ def list_channels(self) -> list[ChannelInfo]:
301
+ """Inbound connections (id = connection name) — used by the Log Search filter."""
302
+ return _decode_list(self._get("/channels"), ChannelInfo)
303
+
304
+ def connections(self) -> list[ConnectionRow]:
305
+ return _decode_list(self._get("/connections"), ConnectionRow)
306
+
307
+ # --- code-first connection operations ------------------------------------
308
+
309
+ def start_connection(self, name: str) -> None:
310
+ self._request("POST", f"/connections/{name}/start")
311
+
312
+ def stop_connection(self, name: str) -> None:
313
+ self._request("POST", f"/connections/{name}/stop")
314
+
315
+ def restart_connection(self, name: str) -> None:
316
+ self._request("POST", f"/connections/{name}/restart")
317
+
318
+ def purge_connection(self, name: str, scope: str = "all") -> PurgeResult:
319
+ return _decode(
320
+ self._request("POST", f"/connections/{name}/purge", params={"scope": scope}),
321
+ PurgeResult,
322
+ )
323
+
324
+ def list_messages(
325
+ self,
326
+ *,
327
+ channel_id: str | None = None,
328
+ status: str | None = None,
329
+ message_type: str | None = None,
330
+ control_id: str | None = None,
331
+ limit: int = 50,
332
+ offset: int = 0,
333
+ audit_summary: bool = False,
334
+ ) -> MessageList:
335
+ response = self._get(
336
+ "/messages",
337
+ channel_id=channel_id,
338
+ status=status,
339
+ message_type=message_type,
340
+ control_id=control_id,
341
+ limit=limit,
342
+ offset=offset,
343
+ audit_summary=audit_summary or None,
344
+ )
345
+ return _decode(response, MessageList)
346
+
347
+ def get_message(self, message_id: str) -> MessageDetail:
348
+ return _decode(self._get(f"/messages/{message_id}"), MessageDetail)
349
+
350
+ def replay(self, message_id: str) -> ReplayResult:
351
+ return _decode(self._request("POST", f"/messages/{message_id}/replay"), ReplayResult)
352
+
353
+ # --- dead letters --------------------------------------------------------
354
+
355
+ def list_dead_letters(
356
+ self,
357
+ *,
358
+ channel_id: str | None = None,
359
+ destination_name: str | None = None,
360
+ limit: int = 50,
361
+ offset: int = 0,
362
+ audit_summary: bool = False,
363
+ ) -> DeadLetterList:
364
+ """Dead-lettered deliveries (newest first), optionally scoped to an inbound/outbound."""
365
+ response = self._get(
366
+ "/dead-letters",
367
+ channel_id=channel_id,
368
+ destination_name=destination_name,
369
+ limit=limit,
370
+ offset=offset,
371
+ audit_summary=audit_summary or None,
372
+ )
373
+ return DeadLetterList.model_validate(response.json())
374
+
375
+ def replay_dead_letters(
376
+ self, *, channel_id: str | None = None, destination_name: str | None = None
377
+ ) -> DeadLetterReplayResult:
378
+ """Re-queue dead-lettered deliveries (``None`` scope = all; a channel-scoped user must
379
+ name their channel — an unscoped replay-all is denied server-side)."""
380
+ return DeadLetterReplayResult.model_validate(
381
+ self._request(
382
+ "POST",
383
+ "/dead-letters/replay",
384
+ json={"channel_id": channel_id, "destination_name": destination_name},
385
+ ).json()
386
+ )
387
+
388
+ # --- config --------------------------------------------------------------
389
+
390
+ def reload_config(self, config_dir: str | None = None) -> ReloadResult:
391
+ """Apply code-first config atomically (``None`` = the server's startup --config dir)."""
392
+ return ReloadResult.model_validate(
393
+ self._request("POST", "/config/reload", json={"config_dir": config_dir}).json()
394
+ )
395
+
396
+ def stats(self) -> StatsResponse:
397
+ return _decode(self._get("/stats"), StatsResponse)
398
+
399
+ def status(self) -> SystemStatus:
400
+ return _decode(self._get("/status"), SystemStatus)
401
+
402
+ def cluster_status(self) -> ClusterStatus:
403
+ """This node's active-passive role / leadership (cheap in-memory read; MONITORING_READ)."""
404
+ return _decode(self._get("/cluster/status"), ClusterStatus)
405
+
406
+ def cluster_nodes(self) -> ClusterNodeList:
407
+ """Cluster membership + the derived live leader + lease state (MONITORING_READ)."""
408
+ return _decode(self._get("/cluster/nodes"), ClusterNodeList)
409
+
410
+ def integrity_check(self) -> IntegrityResult:
411
+ # The DB integrity scan (PRAGMA quick_check) is exactly the call that runs long on a large
412
+ # store — the blanket short timeout would spuriously report it as "could not reach engine".
413
+ # Give this one request a generous timeout (review M-27).
414
+ return _decode(
415
+ self._request("POST", "/status/integrity-check", timeout=httpx.Timeout(300.0)),
416
+ IntegrityResult,
417
+ )
418
+
419
+ # --- authentication ------------------------------------------------------
420
+
421
+ @property
422
+ def token(self) -> str | None:
423
+ return self._token
424
+
425
+ @property
426
+ def current_user(self) -> CurrentUser | None:
427
+ return self._user
428
+
429
+ def can(self, permission: str) -> bool:
430
+ """True if the signed-in user holds ``permission`` (False when not signed in)."""
431
+ return self._user is not None and permission in self._user.permissions
432
+
433
+ def set_token(self, token: str) -> None:
434
+ """Adopt an existing token (e.g. from the OS keyring) and refresh the cached user."""
435
+ self._token = token
436
+ self._user = self.me()
437
+
438
+ def clear_auth(self) -> None:
439
+ self._token = None
440
+ self._user = None
441
+
442
+ def providers(self) -> ProvidersInfo:
443
+ """Which login methods the engine offers (callable before authenticating)."""
444
+ return _decode(self._get("/auth/providers"), ProvidersInfo)
445
+
446
+ def login(self, username: str, password: str, *, provider: str = "local") -> LoginResponse:
447
+ result = _decode(
448
+ self._request(
449
+ "POST",
450
+ "/auth/login",
451
+ json={"username": username, "password": password, "provider": provider},
452
+ ),
453
+ LoginResponse,
454
+ )
455
+ self._token = result.token
456
+ self._user = result.user
457
+ return result
458
+
459
+ def me(self) -> CurrentUser:
460
+ return _decode(self._get("/auth/me"), CurrentUser)
461
+
462
+ def logout(self) -> None:
463
+ try:
464
+ self._request("POST", "/auth/logout")
465
+ finally:
466
+ self.clear_auth()
467
+
468
+ def change_password(self, current_password: str, new_password: str) -> None:
469
+ self._request(
470
+ "POST",
471
+ "/me/password",
472
+ json={"current_password": current_password, "new_password": new_password},
473
+ )
474
+ self.clear_auth() # the server revokes sessions on change; sign in again
475
+
476
+ # --- sessions (WP-10) ----------------------------------------------------
477
+
478
+ def list_sessions(self) -> list[SessionInfo]:
479
+ """The signed-in user's active sessions; the entry with ``current=True`` is this one."""
480
+ return _decode(self._get("/me/sessions"), SessionList).sessions
481
+
482
+ def revoke_session(self, session_id: str) -> None:
483
+ """Revoke one of the user's own sessions by its ``id`` (the session's ``token_hash``)."""
484
+ self._request("DELETE", f"/me/sessions/{session_id}")
485
+
486
+ def revoke_other_sessions(self) -> str:
487
+ """Revoke every session except this one ("sign out everywhere else"); returns the summary."""
488
+ return _decode(self._request("DELETE", "/me/sessions"), SimpleMessage).detail
489
+
490
+ def revoke_user_sessions(self, user_id: str) -> str:
491
+ """Admin force-sign-out: revoke all of ``user_id``'s sessions; returns the summary."""
492
+ return _decode(self._request("DELETE", f"/users/{user_id}/sessions"), SimpleMessage).detail
493
+
494
+ # --- user administration -------------------------------------------------
495
+
496
+ def list_roles(self) -> list[RoleInfo]:
497
+ return _decode_list(self._get("/roles"), RoleInfo)
498
+
499
+ def list_users(self) -> list[UserSummary]:
500
+ return _decode_list(self._get("/users"), UserSummary)
501
+
502
+ def create_user(
503
+ self,
504
+ username: str,
505
+ password: str,
506
+ *,
507
+ display_name: str | None = None,
508
+ email: str | None = None,
509
+ roles: list[str] | None = None,
510
+ ) -> UserSummary:
511
+ body = {
512
+ "username": username,
513
+ "password": password,
514
+ "display_name": display_name,
515
+ "email": email,
516
+ "roles": roles or [],
517
+ }
518
+ return _decode(self._request("POST", "/users", json=body), UserSummary)
519
+
520
+ def set_user_roles(self, user_id: str, roles: list[str]) -> None:
521
+ self._request("PUT", f"/users/{user_id}/roles", json={"roles": roles})
522
+
523
+ def get_channel_scope(self, user_id: str) -> list[str] | None:
524
+ """A user's per-channel RBAC scope (``None`` = all channels)."""
525
+ return _decode(self._get(f"/users/{user_id}/channel-scope"), ChannelScope).channels
526
+
527
+ def set_channel_scope(self, user_id: str, channels: list[str] | None) -> None:
528
+ """Set a user's per-channel RBAC scope (``None`` = all channels)."""
529
+ self._request("PUT", f"/users/{user_id}/channel-scope", json={"channels": channels})
530
+
531
+ def delete_user(self, user_id: str) -> None:
532
+ self._request("DELETE", f"/users/{user_id}")
533
+
534
+ def audit(self, *, limit: int = 100) -> AuditList:
535
+ return _decode(self._get("/audit", limit=limit), AuditList)
536
+
537
+ def ad_group_map(self) -> AdGroupMap:
538
+ return _decode(self._get("/ad-group-map"), AdGroupMap)
539
+
540
+ def set_ad_group_map(self, entries: list[tuple[str, str]]) -> None:
541
+ payload = {
542
+ "entries": [AdGroupMapEntry(ad_group=g, role=r).model_dump() for g, r in entries]
543
+ }
544
+ self._request("PUT", "/ad-group-map", json=payload)
545
+
546
+
547
+ def _error_detail(response: httpx.Response) -> str:
548
+ try:
549
+ detail = response.json().get("detail")
550
+ except Exception:
551
+ detail = None
552
+ return f"{response.status_code}: {detail or response.text or response.reason_phrase}"