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,260 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Managed, hot-reloadable **code sets** (reference lookup tables) for the message graph.
4
+
5
+ A code-first Router/Handler often needs a reference table — an Epic diet code → a food-service
6
+ system value, a facility code → a downstream mnemonic. Hand-maintained Python dicts in the config dir work, but they
7
+ aren't operated like config: they don't reload with the graph, they aren't surfaced as data, and an
8
+ edit means a code change. A **code set** is the managed alternative: drop a ``codesets/<name>.csv``
9
+ or ``codesets/<name>.toml`` next to the config bundle, then look it up with ``code_set("name")``.
10
+
11
+ A code set is **read-only reference data** — one frozen :class:`CodeSet` instance is shared by every
12
+ transform, so a Router/Handler must never mutate it. The lookup itself is pure (key in → value out),
13
+ which keeps it compatible with the staged-pipeline **pure-re-run** invariant (ADR 0001 / CLAUDE.md
14
+ §2). **One caveat:** a hot-reload that *changes* a code set between a run and a crash-re-run can make
15
+ the re-run derive a different output than the original. That is acceptable for reference data (a code
16
+ set is intentionally operator-editable, and a reload is an explicit, audited act) — but it is the one
17
+ way a transform's output can legitimately differ across a re-run, so document it where you document
18
+ the transform.
19
+
20
+ **Location.** ``codesets/`` is resolved **relative to the ``--config`` dir** (a config bundle carries
21
+ its own reference tables and reloads with it) — distinct from ``environments/`` (cwd-level endpoint
22
+ values for :func:`~messagefoundry.config.wiring.env`). A missing ``codesets/`` dir is fine (no code
23
+ sets); a referenced-but-missing *name* fails **loud** (:class:`~messagefoundry.config.wiring.WiringError`),
24
+ exactly like a missing ``env()`` key — surfaced by ``validate`` / ``check`` / reload.
25
+
26
+ **Formats** (auto-detected by extension; the code-set NAME is the filename stem):
27
+
28
+ * **CSV** — a header row; the **first column is the lookup key**. If exactly one other column remains,
29
+ the value is that scalar (``str``); if several remain, the value is a ``dict`` ``{header: cell}``.
30
+ Read via :class:`csv.DictReader`. A duplicate key is a load error (fail loud).
31
+ * **TOML** — a flat table ``key = value`` → ``{key: scalar}`` (mirroring
32
+ :mod:`messagefoundry.config.environments`); a nested table value → ``{key: {…}}``. Read via
33
+ :mod:`tomllib`.
34
+
35
+ **Resolution.** :func:`~messagefoundry.config.wiring.load_config` loads every code set into a registry
36
+ and makes it the **active** set *before* importing config modules (so a module-top-level
37
+ ``DIET = code_set("epic_diets")`` resolves), and the :class:`RegistryRunner` re-publishes the live
38
+ registry's set while a Router/Handler runs (so a call-time ``code_set("epic_diets").get(x)`` inside a
39
+ handler resolves too). A reload swaps the active set atomically. Use :func:`activated` to scope an
40
+ active set; :func:`set_active` to publish one outside a ``with`` block.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import csv
46
+ import logging
47
+ import tomllib
48
+ from collections.abc import Iterator, Mapping
49
+ from contextlib import contextmanager
50
+ from contextvars import ContextVar
51
+ from pathlib import Path
52
+ from typing import Any
53
+
54
+ __all__ = [
55
+ "CODESETS_DIR_NAME",
56
+ "CodeSet",
57
+ "load_code_set",
58
+ "load_code_sets",
59
+ "code_set",
60
+ "set_active",
61
+ "activated",
62
+ "CodeSetError",
63
+ ]
64
+
65
+ #: The code-set directory name, resolved relative to the ``--config`` dir.
66
+ CODESETS_DIR_NAME = "codesets"
67
+
68
+ _log = logging.getLogger(__name__)
69
+
70
+
71
+ class CodeSetError(ValueError):
72
+ """A code set is malformed, has a duplicate key, or was referenced but doesn't exist.
73
+
74
+ A subclass of :class:`ValueError`; :func:`messagefoundry.config.wiring.code_set` re-raises these
75
+ as :class:`~messagefoundry.config.wiring.WiringError` so a bad/missing code set is surfaced by
76
+ ``validate`` / ``check`` / reload exactly like a missing ``env()`` key (fail loud)."""
77
+
78
+
79
+ class CodeSet(Mapping[str, Any]):
80
+ """A frozen, read-only reference table: ``name`` + an immutable ``key → value`` mapping.
81
+
82
+ Behaves like a read-only ``dict`` (``cs[key]``, ``cs.get(key, default)``, ``key in cs``,
83
+ ``len(cs)``, iteration) but rejects mutation — one instance is shared across every transform, so
84
+ reference data can't be edited from a handler. ``cs[missing]`` raises a :class:`KeyError` naming
85
+ the code set; ``cs.get(missing, default)`` returns the default."""
86
+
87
+ __slots__ = ("_name", "_data")
88
+
89
+ def __init__(self, name: str, data: Mapping[str, Any]) -> None:
90
+ self._name = name
91
+ self._data: dict[str, Any] = dict(data)
92
+
93
+ @property
94
+ def name(self) -> str:
95
+ return self._name
96
+
97
+ def __getitem__(self, key: str) -> Any:
98
+ try:
99
+ return self._data[key]
100
+ except KeyError:
101
+ # Name the code set so a miss is self-explanatory in a transform traceback.
102
+ raise KeyError(f"key {key!r} not in code set {self._name!r}") from None
103
+
104
+ def __contains__(self, key: object) -> bool:
105
+ return key in self._data
106
+
107
+ def __iter__(self) -> Iterator[str]:
108
+ return iter(self._data)
109
+
110
+ def __len__(self) -> int:
111
+ return len(self._data)
112
+
113
+ def __repr__(self) -> str:
114
+ return f"CodeSet(name={self._name!r}, entries={len(self._data)})"
115
+
116
+
117
+ # --- loading -----------------------------------------------------------------
118
+
119
+
120
+ def load_code_set(path: str | Path) -> CodeSet:
121
+ """Load one ``.csv``/``.toml`` file into a :class:`CodeSet` (the NAME is the file stem).
122
+
123
+ Auto-detects the format by extension. Raises :class:`CodeSetError` (naming the file) for an
124
+ unknown extension, a malformed file, or a duplicate key — never silently drops data."""
125
+ path = Path(path)
126
+ name = path.stem
127
+ suffix = path.suffix.lower()
128
+ if suffix == ".csv":
129
+ data = _load_csv(path)
130
+ elif suffix == ".toml":
131
+ data = _load_toml(path)
132
+ else:
133
+ raise CodeSetError(
134
+ f"code set {path.name!r}: unsupported extension {suffix!r} (use .csv or .toml)"
135
+ )
136
+ return CodeSet(name, data)
137
+
138
+
139
+ def load_code_sets(codesets_dir: str | Path) -> dict[str, CodeSet]:
140
+ """Load every ``*.csv``/``*.toml`` in ``codesets_dir`` into a ``{name: CodeSet}`` registry.
141
+
142
+ A missing directory is **not** an error (returns ``{}`` — a config bundle need not ship any code
143
+ sets). Two files producing the same name (e.g. ``diets.csv`` and ``diets.toml``) is a
144
+ :class:`CodeSetError` (ambiguous), as is any malformed file."""
145
+ codesets_dir = Path(codesets_dir)
146
+ if not codesets_dir.is_dir():
147
+ return {}
148
+ out: dict[str, CodeSet] = {}
149
+ # Sorted for a deterministic load order, so a clash error names a stable "first" file.
150
+ for path in sorted(codesets_dir.iterdir()):
151
+ if not path.is_file() or path.suffix.lower() not in (".csv", ".toml"):
152
+ continue
153
+ cs = load_code_set(path)
154
+ if cs.name in out:
155
+ raise CodeSetError(
156
+ f"duplicate code set name {cs.name!r} in {codesets_dir} — two files (different "
157
+ "extensions) resolve to the same name; rename one"
158
+ )
159
+ out[cs.name] = cs
160
+ return out
161
+
162
+
163
+ def _load_csv(path: Path) -> dict[str, Any]:
164
+ """CSV with a header row: first column = key; one other column → scalar, several → ``{header: cell}``."""
165
+ data: dict[str, Any] = {}
166
+ with path.open(newline="", encoding="utf-8") as fh:
167
+ reader = csv.DictReader(fh)
168
+ fields = reader.fieldnames
169
+ if not fields:
170
+ raise CodeSetError(f"code set {path.name!r}: empty CSV (no header row)")
171
+ key_field, *value_fields = fields
172
+ if not value_fields:
173
+ raise CodeSetError(
174
+ f"code set {path.name!r}: CSV needs a key column plus at least one value column"
175
+ )
176
+ single = len(value_fields) == 1
177
+ for row in reader:
178
+ key = row.get(key_field)
179
+ if key is None:
180
+ continue # short/blank row — DictReader fills missing cells with None
181
+ if key in data:
182
+ raise CodeSetError(f"code set {path.name!r}: duplicate key {key!r}")
183
+ if single:
184
+ data[key] = row.get(value_fields[0])
185
+ else:
186
+ data[key] = {vf: row.get(vf) for vf in value_fields}
187
+ return data
188
+
189
+
190
+ def _load_toml(path: Path) -> dict[str, Any]:
191
+ """Flat TOML table → ``{key: scalar}``; a nested table value → ``{key: {…}}`` (mirrors environments)."""
192
+ try:
193
+ with path.open("rb") as fh:
194
+ raw = tomllib.load(fh)
195
+ except (tomllib.TOMLDecodeError, OSError) as exc:
196
+ raise CodeSetError(f"code set {path.name!r}: invalid TOML — {exc}") from exc
197
+ # tomllib already rejects duplicate keys (TOMLDecodeError), so no extra dup check is needed.
198
+ return dict(raw)
199
+
200
+
201
+ # --- active-set holder + accessor --------------------------------------------
202
+
203
+ # The active code sets, as a ContextVar so import-time (load_config publishes the set, then imports
204
+ # config modules in the same thread/context) AND call-time (the RegistryRunner re-publishes the live
205
+ # set around a router/handler run) both resolve, and a reload swaps cleanly by resetting the var (no
206
+ # stale set leaks — unlike a bare module-global, a ContextVar's reset token restores the prior value
207
+ # even if loads/reloads overlap). Defaults to None = "no active set" (a code_set() call then fails
208
+ # loud) rather than {} so "no codesets dir" and "called outside a load/run" stay distinguishable.
209
+ _active: ContextVar[dict[str, CodeSet] | None] = ContextVar("mefor_active_code_sets", default=None)
210
+
211
+
212
+ def set_active(code_sets: dict[str, CodeSet] | None) -> Any:
213
+ """Publish ``code_sets`` as the active set and return a reset token (pass it to :func:`reset`).
214
+
215
+ Used by callers that can't bracket the active span with a ``with`` (e.g. an async worker that
216
+ publishes around a single transform call). Prefer :func:`activated` where a ``with`` block fits."""
217
+ return _active.set(code_sets)
218
+
219
+
220
+ def reset(token: Any) -> None:
221
+ """Restore the active set to what it was before the matching :func:`set_active`."""
222
+ _active.reset(token)
223
+
224
+
225
+ @contextmanager
226
+ def activated(code_sets: dict[str, CodeSet] | None) -> Iterator[None]:
227
+ """Make ``code_sets`` the active set for the duration of the ``with`` block, then restore.
228
+
229
+ The loader brackets config-module import with this; a runner brackets each router/handler run with
230
+ it — so ``code_set()`` resolves both at import time and at call time, and the prior set is always
231
+ restored (clean swap, no leak)."""
232
+ token = _active.set(code_sets)
233
+ try:
234
+ yield
235
+ finally:
236
+ _active.reset(token)
237
+
238
+
239
+ def code_set(name: str) -> CodeSet:
240
+ """Return the active code set ``name`` (a frozen, read-only :class:`CodeSet`).
241
+
242
+ Call it at a config module's top level to capture a table once (``DIET = code_set("epic_diets")``)
243
+ or inside a handler at call time (``code_set("epic_diets").get(x)``) — both resolve against the
244
+ set the loader/runner has published. A missing code set raises :class:`CodeSetError` (fail loud);
245
+ :func:`messagefoundry.config.wiring.code_set` (the authoring surface) re-raises it as a
246
+ :class:`~messagefoundry.config.wiring.WiringError`."""
247
+ active = _active.get()
248
+ if active is None:
249
+ raise CodeSetError(
250
+ f"code_set({name!r}) called with no active code sets — code sets resolve only while a "
251
+ "config bundle is being loaded or its graph is running (load it via load_config())"
252
+ )
253
+ try:
254
+ return active[name]
255
+ except KeyError:
256
+ available = ", ".join(sorted(active)) or "(none)"
257
+ raise CodeSetError(
258
+ f"no such code set {name!r} — expected a file codesets/{name}.csv or "
259
+ f"codesets/{name}.toml relative to the --config dir; available: {available}"
260
+ ) from None
@@ -0,0 +1,200 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Write ``connections.toml`` — the comment/format-preserving editor behind the ``connection`` CLI
4
+ (ADR 0007).
5
+
6
+ The file is edited by hand **and** by the VS Code GUI, so a write must touch only the target
7
+ connection and leave every other table's comments + formatting byte-stable — hence ``tomlkit``
8
+ (style-preserving) rather than a plain serializer. Each mutation **validates the whole config dir**
9
+ (load + the connector/egress build-check) BEFORE it persists, and writes atomically (temp + replace,
10
+ owner-only perms); if validation fails the prior content is restored, so a bad edit never lands.
11
+
12
+ The validation is injected as a callback so this module stays free of service-settings/engine imports
13
+ (and is trivially testable); the ``connection`` CLI builds the callback from ``load_config`` +
14
+ ``build_check_registry`` against the local ``[egress]`` allowlist and active environment.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ import tomlkit
24
+
25
+ from messagefoundry.config.connections_file import CONNECTIONS_FILE_NAME
26
+ from messagefoundry.config.wiring import WiringError
27
+
28
+ # Scalar fields written (in this order) at the top of a connection table; sub-tables (settings/retry/
29
+ # buildup) follow. Only keys present in the input are emitted — the loader rejects misplaced ones.
30
+ _SCALAR_FIELDS = (
31
+ "name",
32
+ "transport",
33
+ "router",
34
+ "content_type",
35
+ "ack_mode",
36
+ "ack_after",
37
+ "strict",
38
+ "hl7_version",
39
+ "ordering",
40
+ "internal_error",
41
+ "simulate",
42
+ )
43
+ _SUB_TABLES = ("settings", "retry", "buildup")
44
+
45
+ Validate = Callable[[Path], None]
46
+
47
+
48
+ def list_connections(config_dir: str | Path) -> list[dict[str, Any]]:
49
+ """The data-authored connections in ``config_dir``'s ``connections.toml`` (``[]`` if none).
50
+
51
+ Each entry is a plain dict with a ``direction`` key added — the editable set the GUI manages
52
+ (code-first ``inbound()``/``outbound()`` connections are read-only and not listed here)."""
53
+ path = _path(config_dir)
54
+ if not path.is_file():
55
+ return []
56
+ doc = tomlkit.parse(path.read_text(encoding="utf-8"))
57
+ out: list[dict[str, Any]] = []
58
+ for direction in ("inbound", "outbound"):
59
+ for table in doc.get(direction, []):
60
+ entry = dict(table.unwrap())
61
+ entry["direction"] = direction
62
+ out.append(entry)
63
+ return out
64
+
65
+
66
+ def upsert_connection(
67
+ config_dir: str | Path, obj: dict[str, Any], *, validate: Validate
68
+ ) -> dict[str, Any]:
69
+ """Insert or replace the connection described by ``obj`` (the connection as a JSON object).
70
+
71
+ Replaces the same-named entry in place (preserving every *other* table's comments) or appends a
72
+ new one. Validates the whole dir before persisting; raises :class:`WiringError` on bad input or a
73
+ config that wouldn't load (a duplicate vs. a code-first name, unknown router, egress-denied host,
74
+ …) — leaving ``connections.toml`` untouched."""
75
+ _validate_input(obj)
76
+ path = _path(config_dir)
77
+ original = path.read_text(encoding="utf-8") if path.is_file() else None
78
+ doc = tomlkit.parse(original) if original is not None else tomlkit.document()
79
+
80
+ direction = obj["direction"]
81
+ aot = doc.get(direction)
82
+ if aot is None:
83
+ aot = tomlkit.aot()
84
+ doc[direction] = aot
85
+ new_table = _build_table(obj)
86
+ for index in range(len(aot)):
87
+ if aot[index].get("name") == obj["name"]:
88
+ aot[index] = new_table # replace in place — other entries' trivia untouched
89
+ break
90
+ else:
91
+ aot.append(new_table)
92
+
93
+ _write_validated(path, tomlkit.dumps(doc), original, validate)
94
+ return {"op": "upsert", "direction": direction, "name": obj["name"]}
95
+
96
+
97
+ def remove_connection(config_dir: str | Path, name: str, *, validate: Validate) -> dict[str, Any]:
98
+ """Remove the data-authored connection ``name``. Raises :class:`WiringError` if it isn't in
99
+ ``connections.toml`` (a code-first connection can't be removed here)."""
100
+ path = _path(config_dir)
101
+ if not path.is_file():
102
+ raise WiringError(f"no {CONNECTIONS_FILE_NAME} in {config_dir}")
103
+ original = path.read_text(encoding="utf-8")
104
+ doc = tomlkit.parse(original)
105
+
106
+ removed = False
107
+ for direction in ("inbound", "outbound"):
108
+ aot = doc.get(direction)
109
+ if aot is None:
110
+ continue
111
+ for index in range(len(aot)):
112
+ if aot[index].get("name") == name:
113
+ del aot[index]
114
+ removed = True
115
+ break
116
+ if removed:
117
+ break
118
+ if not removed:
119
+ raise WiringError(
120
+ f"connection {name!r} is not in {CONNECTIONS_FILE_NAME} "
121
+ "(a code-authored connection can't be removed here)"
122
+ )
123
+
124
+ _write_validated(path, tomlkit.dumps(doc), original, validate)
125
+ return {"op": "remove", "name": name}
126
+
127
+
128
+ # --- internals ---------------------------------------------------------------
129
+
130
+
131
+ def _path(config_dir: str | Path) -> Path:
132
+ return Path(config_dir) / CONNECTIONS_FILE_NAME
133
+
134
+
135
+ def _validate_input(obj: Any) -> None:
136
+ if not isinstance(obj, dict):
137
+ raise WiringError("connection must be a JSON object")
138
+ if obj.get("direction") not in ("inbound", "outbound"):
139
+ raise WiringError("connection 'direction' must be 'inbound' or 'outbound'")
140
+ if not isinstance(obj.get("name"), str) or not obj["name"]:
141
+ raise WiringError("connection 'name' must be a non-empty string")
142
+ if not isinstance(obj.get("transport"), str) or not obj["transport"]:
143
+ raise WiringError("connection 'transport' must be a non-empty string")
144
+
145
+
146
+ def _build_table(obj: dict[str, Any]) -> Any:
147
+ table = tomlkit.table()
148
+ for key in _SCALAR_FIELDS:
149
+ if obj.get(key) is not None:
150
+ table[key] = obj[key]
151
+ for key in _SUB_TABLES:
152
+ value = obj.get(key)
153
+ if value:
154
+ table[key] = _sub_table(value)
155
+ return table
156
+
157
+
158
+ def _sub_table(data: dict[str, Any]) -> Any:
159
+ sub = tomlkit.table()
160
+ for key, value in data.items():
161
+ sub[key] = _toml_value(value)
162
+ return sub
163
+
164
+
165
+ def _toml_value(value: Any) -> Any:
166
+ """A nested dict (an ``env()`` ref like ``{env = "k"}``, or REST headers) becomes an inline table;
167
+ everything else is written verbatim."""
168
+ if isinstance(value, dict):
169
+ inline = tomlkit.inline_table()
170
+ for key, item in value.items():
171
+ inline[key] = item
172
+ return inline
173
+ return value
174
+
175
+
176
+ def _write_validated(path: Path, new_text: str, original: str | None, validate: Validate) -> None:
177
+ """Atomically write ``new_text``, validate the dir, and roll back to ``original`` on failure."""
178
+ _atomic_write(path, new_text)
179
+ try:
180
+ validate(path.parent)
181
+ except BaseException:
182
+ if original is None:
183
+ path.unlink(missing_ok=True)
184
+ else:
185
+ _atomic_write(path, original)
186
+ raise
187
+ _secure_file(path)
188
+
189
+
190
+ def _atomic_write(path: Path, text: str) -> None:
191
+ tmp = path.with_name(path.name + ".tmp")
192
+ tmp.write_text(text, encoding="utf-8")
193
+ os.replace(tmp, path)
194
+
195
+
196
+ def _secure_file(path: Path) -> None:
197
+ # Owner-only permissions (defence in depth). Reuse the store's primitive; tolerate its absence.
198
+ from messagefoundry.store.store import _secure_file as _secure
199
+
200
+ _secure(path)