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
File without changes
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """PHI redaction for the exception/logging path (WP-6c; ASVS 16.2.5, PHI.md P1-3).
4
+
5
+ Inbound HL7 is attacker-/PHI-bearing, and a Router/Handler is user code that can do
6
+ ``raise ValueError(f"bad value in {raw}")`` — which would otherwise carry the full message body into
7
+ the stored ``last_error``/``message_events.detail`` and any log line built from it. :func:`safe_exc`
8
+ is the **chokepoint**: every exception rendered into a stored disposition or a log is routed through
9
+ it, so HL7-structured content is scrubbed while the exception **type** (the useful, non-PHI part) is
10
+ kept.
11
+
12
+ This is a conservative *redaction* of HL7-shaped content — **not** de-identification (that is a
13
+ separate, centralized framework; see PHI.md §9). It errs toward over-redaction; the residual control
14
+ for free-text PHI a user script invents (e.g. a bare ``"DOE^JANE"``) remains the "never put PHI in an
15
+ exception message" convention. Pure stdlib (``re`` only), so it can be used from any engine package.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ __all__ = ["redact", "safe_exc", "safe_text"]
23
+
24
+ _REDACTED = "[redacted]"
25
+ #: Max characters of a (redacted) exception message to keep — a raw HL7 body is long, so bound what
26
+ #: reaches a stored ``last_error`` or a log line even after redaction.
27
+ _DEFAULT_LIMIT = 200
28
+
29
+ #: An HL7 **segment** span: a 3-char segment ID (``MSH``/``PID``/``OBX``/…) immediately followed by the
30
+ #: field separator and field data to end-of-line. Catches a raw message (or fragment) embedded in an
31
+ #: exception — the realistic vector. The segment ID is kept (not PHI, useful); the field data is cut.
32
+ _HL7_SEGMENT = re.compile(r"\b([A-Z][A-Z0-9]{2})\|[^\r\n]*")
33
+ #: A run carrying **≥2 HL7 delimiters** (``| ^ ~ &``) — a field/component dump like ``100^^^H^MR`` or
34
+ #: ``DOE^JANE^M`` that may be PHI even without a segment header. The non-delimiter runs use **possessive**
35
+ #: quantifiers (``*+``, Python 3.11+): the char classes are disjoint from the delimiters, so possessive
36
+ #: matching can't change *what* matches, but it makes the scan **linear** — a long delimiter-free run
37
+ #: (e.g. ``"a"*5000`` in a hostile exception string) can't trigger quadratic backtracking.
38
+ _HL7_FIELD_RUN = re.compile(r"[^\s|^~&]*+[|^~&][^\s|^~&]*+(?:[|^~&][^\s|^~&]*+)+")
39
+
40
+
41
+ def redact(text: str) -> str:
42
+ """Scrub HL7 segment/field content (potential PHI) from free text, keeping segment IDs. Conservative
43
+ (errs toward over-redaction); the goal is that a raw HL7 body embedded in an exception message can't
44
+ reach a log or the stored ``last_error``/``detail``. NOT de-identification (PHI.md §9)."""
45
+ if not text:
46
+ return text
47
+ scrubbed = _HL7_SEGMENT.sub(lambda m: f"{m.group(1)}|{_REDACTED}", text)
48
+ return _HL7_FIELD_RUN.sub(_REDACTED, scrubbed)
49
+
50
+
51
+ def safe_text(text: str, *, limit: int = _DEFAULT_LIMIT) -> str:
52
+ """A PHI-redacted, length-bounded rendering of a free-text diagnostic string — the string analog of
53
+ :func:`safe_exc`, for error/detail text that isn't an exception object (joined strict-validation
54
+ errors, a ``last_error`` built at the store layer, a connector's reply-parse note). HL7-shaped content
55
+ is scrubbed (:func:`redact`) and the result truncated. Idempotent on already-:func:`safe_text`'d
56
+ input (``redact`` is a fixed point once delimiter runs are gone), so it is safe to re-apply as a
57
+ store-layer chokepoint over values a caller may already have scrubbed."""
58
+ message = redact(text).strip()
59
+ if len(message) > limit:
60
+ message = f"{message[:limit]}…(+{len(message) - limit} chars)"
61
+ return message
62
+
63
+
64
+ def safe_exc(exc: BaseException, *, limit: int = _DEFAULT_LIMIT) -> str:
65
+ """A PHI-redacted, length-bounded rendering of ``exc`` for a stored ``last_error``/``detail`` or a
66
+ log line. Always keeps the exception **type** (safe + most useful); the message is redacted
67
+ (:func:`redact`) and truncated — so a Router/Handler that did ``raise ValueError(f"...{raw}")``
68
+ can't leak the HL7 body into the store or logs."""
69
+ name = type(exc).__name__
70
+ message = safe_text(str(exc), limit=limit)
71
+ return f"{name}: {message}" if message else name
@@ -0,0 +1,321 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Scaffold a standalone **config repo** for the ``messagefoundry init`` command.
4
+
5
+ A deploying organization keeps its *configuration* (Connections/Routers/Handlers, code sets, and
6
+ per-environment values) in its **own** repo, with the engine as a **read-only, version-pinned
7
+ dependency** it never edits (ADR 0017). This module lays down that repo's skeleton: a runnable starter
8
+ feed, ``environments/<env>.toml`` value stubs, a synthetic fixture, an instance ``messagefoundry.toml``,
9
+ a pinned ``requirements.txt``, a CI ``check`` workflow, ``.vscode`` settings the extension reads, and a
10
+ README — everything an analyst needs to author + validate + deploy config without touching engine source.
11
+
12
+ The templates are plain strings (they ship in the wheel as part of this module — no package-data
13
+ config). ``scaffold()`` writes them and never overwrites an existing file.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from messagefoundry import __version__
21
+
22
+ # A runnable starter feed: receive ADT over MLLP, archive admit/register/update events to a file. Uses
23
+ # literals (not env()) so `messagefoundry check` is green on the first commit; the docstring shows the
24
+ # env() upgrade. The router/handler defs carry `# type: ignore[no-untyped-def]` (the engine calls them
25
+ # with a Message; user config isn't type-checked against that signature).
26
+ _STARTER_FEED = '''\
27
+ """Starter feed — receive ADT over MLLP, archive admit/register/update events to a file.
28
+
29
+ Replace this with your own Connections, Routers, and Handlers. The authoring surface is the top-level
30
+ ``messagefoundry`` package (inbound / outbound / @router / @handler / Send / Message / MLLP / File /
31
+ env / code_set / current_environment / ...). See docs/CONNECTIONS.md in the engine.
32
+
33
+ Connection names follow ``[TYPE]_[PARTNER]_[MESSAGE]``: ``IB_EXAMPLE_ADT`` is an inbound MLLP listener;
34
+ ``FILE-OUT_EXAMPLE_ADT`` is an outbound file writer.
35
+
36
+ Per-environment values: replace a literal like ``port=2575`` with ``port=env("example_adt_port")`` and
37
+ add ``example_adt_port = 2575`` to ``environments/<env>.toml`` (those value files resolve against the
38
+ project root — launch ``serve`` from the repo root, or pin it via ``serve --project-root`` /
39
+ ``[environments].base_dir``; see the README). Secrets come from ``MEFOR_VALUE_<KEY>`` env vars, never
40
+ the value files.
41
+ """
42
+
43
+ from messagefoundry import File, MLLP, Send, handler, inbound, outbound, router
44
+
45
+ inbound("IB_EXAMPLE_ADT", MLLP(port=2575), router="example_adt_router")
46
+ outbound("FILE-OUT_EXAMPLE_ADT", File(directory="./out/example", filename="{MSH-10}.hl7"))
47
+
48
+
49
+ @router("example_adt_router")
50
+ def route(msg): # type: ignore[no-untyped-def]
51
+ # The router sees EVERY received message and returns the handler(s) to run ([] = UNROUTED).
52
+ if msg["MSH-9.1"] != "ADT":
53
+ return []
54
+ return ["example_adt_archive"]
55
+
56
+
57
+ @handler("example_adt_archive")
58
+ def archive(msg): # type: ignore[no-untyped-def]
59
+ # Filter -> (transform) -> Send. Only admit/register/update events are archived; others FILTERED.
60
+ if msg["MSH-9.2"] not in ("A01", "A04", "A08"):
61
+ return None
62
+ return Send("FILE-OUT_EXAMPLE_ADT", msg)
63
+ '''
64
+
65
+ # A synthetic ADT^A01 (NO real PHI) that routes + archives, so `check`'s dryrun delivers one message.
66
+ # HL7 segments are CR-separated.
67
+ _FIXTURE_ADT = (
68
+ "MSH|^~\\&|EXAMPLE|FAC|DEST|DEST|20260101120000||ADT^A01|MSG00001|P|2.5.1\r"
69
+ "EVN|A01|20260101120000\r"
70
+ "PID|1||100^^^HOSP^MR||DOE^JANE||19800101|F\r"
71
+ "PV1|1|I|WARD^101^A\r"
72
+ )
73
+
74
+ _ENV_DEV = """\
75
+ # DEV environment values — resolved by env("key") in your config graph (see the engine's
76
+ # docs/CONFIGURATION.md). NON-SECRET values only, versioned here so they're diffable/reviewable.
77
+ # Secrets come from MEFOR_VALUE_<KEY> environment variables, never this file. Keys are lower_snake_case.
78
+ #
79
+ # Selected by [ai].environment = "dev" (or `serve --env dev`). The starter feed uses none; add keys as
80
+ # you switch literals to env(), e.g.:
81
+ # example_adt_port = 2575
82
+ """
83
+
84
+ _ENV_PROD = """\
85
+ # PROD environment values — same shape as dev.toml, with this instance's real (non-secret) endpoints.
86
+ # Secrets come from MEFOR_VALUE_<KEY> environment variables, never this file.
87
+ #
88
+ # Selected by [ai].environment = "prod" (or `serve --env prod`).
89
+ # example_adt_port = 2575
90
+ """
91
+
92
+ # Service settings for ONE instance. Copy per deployed instance (Test/Prod/...) and set its environment
93
+ # + posture + store + egress. Precedence: CLI > MEFOR_<SECTION>_<KEY> env > this file > default.
94
+ _SERVICE_TOML = """\
95
+ # MessageFoundry service settings for THIS instance. Keep one per deployed instance (dev/test/prod/...).
96
+ # Precedence: CLI flag > MEFOR_<SECTION>_<KEY> env > this file > built-in default. Secrets go in env
97
+ # (MEFOR_*), never here. See the engine's docs/CONFIGURATION.md.
98
+
99
+ [store]
100
+ # path = "messagefoundry.db" # SQLite (default). Use a server DB for Test/Prod — see docs/DEPLOY-SERVER-DB.md.
101
+
102
+ [api]
103
+ host = "127.0.0.1" # loopback only; an off-loopback bind requires TLS — see docs/DEPLOYMENT.md.
104
+ port = 8765
105
+
106
+ [ai]
107
+ # The active-environment NAME — REQUIRED (also passable as `serve --env <name>`). Free-form: name
108
+ # instances dev/staging/test/prod/poc/... Built-in names dev/staging/prod carry a default posture; a
109
+ # CUSTOM name MUST also set data_class + production below (posture is never inferred from the name).
110
+ environment = "dev"
111
+ # Security posture, decoupled from the name (ADR 0017). Derived for dev/staging/prod when omitted:
112
+ # data_class = "phi" # synthetic | phi — does this instance carry REAL PHI? (drives at-rest + egress advisories)
113
+ # production = true # production tier? (drives the prod-DEBUG refusal + the AI data-scope ceiling)
114
+
115
+ [environments]
116
+ # Where environments/<env>.toml value files resolve FROM. Default (unset) = the process working
117
+ # directory, so `serve` must be launched from the repo root. Set base_dir to this repo's ABSOLUTE root
118
+ # — or pass `serve --project-root <repo>` — so values resolve no matter the launch CWD (REQUIRED under a
119
+ # service like NSSM, where the working directory isn't the repo root). See docs/CONFIGURATION.md.
120
+ # base_dir = "C:/srv/mefor/this-config-repo"
121
+
122
+ [egress]
123
+ # Lock down outbound destinations on any PHI-carrying instance (recommended for Test/Prod):
124
+ # deny_by_default = true
125
+ # allowed_mllp = ["receiver.test.example:2601"]
126
+ """
127
+
128
+ _VSCODE_SETTINGS = """\
129
+ {
130
+ "messagefoundry.configDir": "config",
131
+ "messagefoundry.messageSetsDir": "messages/sets"
132
+ }
133
+ """
134
+
135
+ # CI gate: verify the pinned engine wheel's build provenance, then install it and run `messagefoundry
136
+ # check` (validate + dryrun + advisory lint) on every PR. `pip install -r requirements.txt` resolves the
137
+ # engine from your configured index — public PyPI (the published releases carry SLSA + PEP 740
138
+ # attestations), the engine's GitHub Release wheel, or a private index.
139
+ _CI_WORKFLOW = """\
140
+ name: check
141
+ on:
142
+ pull_request:
143
+ push:
144
+ branches: [main]
145
+
146
+ jobs:
147
+ # Supply-chain gate (MessageFoundry WP-BL3-07): verify the pinned engine wheel's SLSA build provenance
148
+ # BEFORE installing it, so a registry/mirror substitution of the engine fails the build instead of
149
+ # shipping silently. Fail-closed by default. If your package index strips attestations (some private
150
+ # mirrors do), set the repository variable MEFOR_VERIFY_ENGINE=off to skip this job (see README).
151
+ verify-engine:
152
+ runs-on: ubuntu-latest
153
+ if: ${{ vars.MEFOR_VERIFY_ENGINE != 'off' }}
154
+ steps:
155
+ - uses: actions/checkout@v4
156
+ - uses: actions/setup-python@v5
157
+ with:
158
+ python-version: "3.11"
159
+ - name: Download the pinned engine wheel (no install)
160
+ run: |
161
+ pip download -r requirements.txt --no-deps --only-binary=:all: -d dist-verify
162
+ - name: Verify SLSA build provenance before install
163
+ env:
164
+ GH_TOKEN: ${{ github.token }}
165
+ run: gh attestation verify dist-verify/messagefoundry-*.whl --repo wshallwshall/MessageFoundry
166
+
167
+ check:
168
+ needs: verify-engine
169
+ # Run when verify passed OR was intentionally skipped (MEFOR_VERIFY_ENGINE=off); never when it failed
170
+ # — a failed/cancelled verify-engine fails the gate (fail-closed). `always()` lets this evaluate even
171
+ # though the dependency may have been skipped.
172
+ if: ${{ always() && needs.verify-engine.result != 'failure' && needs.verify-engine.result != 'cancelled' }}
173
+ runs-on: ubuntu-latest
174
+ steps:
175
+ - uses: actions/checkout@v4
176
+ - uses: actions/setup-python@v5
177
+ with:
178
+ python-version: "3.11"
179
+ - name: Install the pinned MessageFoundry engine
180
+ run: pip install -r requirements.txt
181
+ # `check` runs validate + dryrun (the real gate). --no-lint skips the advisory ruff/mypy pass
182
+ # (those tools aren't in requirements.txt); add them and drop --no-lint to lint your config too.
183
+ - name: Validate config (validate + dryrun)
184
+ run: messagefoundry check --config config --messages messages/sets --no-lint
185
+ """
186
+
187
+ _GITIGNORE = """\
188
+ # MessageFoundry config repo — never commit local stores, secrets, captures, or build cruft.
189
+ *.db
190
+ *.db-shm
191
+ *.db-wal
192
+ *.log
193
+ .env
194
+ .env.*
195
+ /out/
196
+ captures/
197
+ __pycache__/
198
+ .venv/
199
+ .mypy_cache/
200
+ .ruff_cache/
201
+ .pytest_cache/
202
+ .DS_Store
203
+ Thumbs.db
204
+ """
205
+
206
+ _GITATTRIBUTES = "# Keep the generated pre-commit hook LF so its shebang works on Windows.\n.mefor-hooks/** text eol=lf\n"
207
+
208
+ _README = """\
209
+ # MessageFoundry configuration
210
+
211
+ This repository is a **MessageFoundry config repo**: it holds *your* integration configuration
212
+ (Connections, Routers, Handlers, code sets, and per-environment values) and drives one or more engine
213
+ instances (e.g. Test, Production). The **engine is a read-only, version-pinned dependency** — you never
214
+ edit it here; you author config against its public surface (ADR 0017).
215
+
216
+ ## Layout
217
+ - `config/` — the `--config` directory: your Connection/Router/Handler modules (and `codesets/`).
218
+ - `environments/<env>.toml` — NON-secret per-environment values for `env("key")` lookups (versioned).
219
+ Secrets come from `MEFOR_VALUE_<KEY>` environment variables, never these files.
220
+ - `messages/sets/` — synthetic HL7 fixtures that gate `messagefoundry check` (no real PHI).
221
+ - `messagefoundry.toml` — this instance's service settings (active environment + posture, store, API, egress).
222
+ - `requirements.txt` — pins the engine version this config targets.
223
+ - `.github/workflows/check.yml` — CI: install the pinned engine + run `messagefoundry check` on every PR.
224
+
225
+ ## Use it
226
+ ```bash
227
+ # 1. Install the pinned engine into a venv (the engine is a read-only dependency):
228
+ python -m venv .venv && . .venv/bin/activate # Windows: .\\.venv\\Scripts\\Activate.ps1
229
+ pip install -r requirements.txt
230
+
231
+ # 2. Validate your config (also runs in CI on every PR):
232
+ messagefoundry check --config config --messages messages/sets
233
+
234
+ # 3. Run an instance. environments/<env>.toml resolves against the project root — by default the
235
+ # process working directory, so launch from the repo root:
236
+ messagefoundry serve --config config --env dev
237
+ # Or pin the root explicitly (an ABSOLUTE path) so it resolves no matter the launch directory —
238
+ # REQUIRED under a service like NSSM, where the CWD isn't the repo root:
239
+ # messagefoundry serve --config config --env dev --project-root /srv/mefor/this-config-repo
240
+ # (Equivalently, set [environments].base_dir to that absolute path in messagefoundry.toml.)
241
+ ```
242
+
243
+ > The engine version in `requirements.txt` is resolved from your configured package index — **PyPI**
244
+ > (`pip install messagefoundry==<version>`; the published releases carry SLSA + PEP 740 attestations),
245
+ > the engine's **GitHub Release wheel**, or a **private index**.
246
+
247
+ ## Engine integrity (supply-chain gate)
248
+ `.github/workflows/check.yml` **verifies the pinned engine wheel's build provenance before installing it**
249
+ (`gh attestation verify` against the MessageFoundry release attestation), so a registry/mirror swap of the
250
+ engine fails CI instead of shipping silently — pinning a version proves *which bytes*, not *who built
251
+ them*. The gate is **fail-closed by default**. If your package index strips attestations (some private
252
+ mirrors do), set the repository variable **`MEFOR_VERIFY_ENGINE=off`** (Settings → Secrets and variables →
253
+ Actions → Variables) to skip it; the `check` job still runs. See the engine's INSTALL-GUIDE for the
254
+ matching manual verify-before-install recipe.
255
+
256
+ ## Environments & posture
257
+ The active environment is **required** and **free-form** — name instances `dev`/`staging`/`test`/`prod`/`poc`/…
258
+ Built-in names `dev`/`staging`/`prod` carry a default security posture; a **custom** name must set
259
+ `[ai].data_class` (`synthetic`|`phi`) and `[ai].production` in `messagefoundry.toml`. One reviewed config
260
+ commit is deployed to every instance; each instance picks its environment at runtime (`--env` or
261
+ `[ai].environment`), so a Test instance never resolves Prod values.
262
+
263
+ The selected `environments/<env>.toml` resolves against the **project root**: by default the process
264
+ working directory (launch from the repo root), or pin it with `serve --project-root <abs-path>` /
265
+ `[environments].base_dir` so it resolves regardless of the launch directory — required under a service
266
+ (e.g. NSSM) where the working directory isn't the repo root.
267
+
268
+ ## Secrets
269
+ Never commit secrets. Per-environment endpoints (non-secret) live in `environments/<env>.toml`; secrets
270
+ (passwords, keys, WS-Security credentials) are injected per instance via `MEFOR_VALUE_*` (graph) and
271
+ `MEFOR_*` (service) environment variables.
272
+
273
+ ---
274
+ Generated by `messagefoundry init`.
275
+ """
276
+
277
+
278
+ def _templates(version: str) -> dict[str, str]:
279
+ """The relative-path -> file-content map for a fresh config repo, pinning ``version``."""
280
+ return {
281
+ "README.md": _README,
282
+ "requirements.txt": f"messagefoundry=={version}\n",
283
+ ".gitignore": _GITIGNORE,
284
+ ".gitattributes": _GITATTRIBUTES,
285
+ ".vscode/settings.json": _VSCODE_SETTINGS,
286
+ ".github/workflows/check.yml": _CI_WORKFLOW,
287
+ "messagefoundry.toml": _SERVICE_TOML,
288
+ "config/IB_EXAMPLE_ADT.py": _STARTER_FEED,
289
+ "environments/dev.toml": _ENV_DEV,
290
+ "environments/prod.toml": _ENV_PROD,
291
+ "messages/sets/example_adt.hl7": _FIXTURE_ADT,
292
+ }
293
+
294
+
295
+ def scaffold(target: str | Path, *, force: bool = False, version: str = __version__) -> list[Path]:
296
+ """Write a starter config-repo skeleton into ``target``; return the files written (sorted).
297
+
298
+ Refuses a **non-empty** ``target`` unless ``force`` is set. An existing file is **never**
299
+ overwritten (even with ``force``) — ``force`` only permits scaffolding the missing files into a
300
+ directory that already has content. ``version`` pins the engine in ``requirements.txt`` (defaults
301
+ to the running engine's version).
302
+ """
303
+ root = Path(target)
304
+ if root.exists() and root.is_dir() and any(root.iterdir()) and not force:
305
+ raise FileExistsError(
306
+ f"{root} is not empty — pass force=True to scaffold the missing files into it "
307
+ "(existing files are left untouched)"
308
+ )
309
+ if root.exists() and not root.is_dir():
310
+ raise NotADirectoryError(f"{root} exists and is not a directory")
311
+
312
+ written: list[Path] = []
313
+ for rel, content in _templates(version).items():
314
+ path = root / rel
315
+ if path.exists():
316
+ continue # never clobber an existing file
317
+ path.parent.mkdir(parents=True, exist_ok=True)
318
+ # newline="" writes the content verbatim — LF for text, the CR-separated HL7 fixture intact.
319
+ path.write_text(content, encoding="utf-8", newline="")
320
+ written.append(path)
321
+ return sorted(written)
@@ -0,0 +1,129 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Windows DPAPI secret-at-rest helper (WP-11d; ASVS 13.3.1/13.3.2).
4
+
5
+ The store encryption key is normally supplied as base64 via ``MEFOR_STORE_ENCRYPTION_KEY`` (the
6
+ cross-platform default). On Windows an operator may instead keep it in a **DPAPI-protected key file**
7
+ (``[store].encryption_key_file``): ``CryptProtectData`` binds the ciphertext to this machine
8
+ (``LOCAL_MACHINE`` scope), so a copied file is useless off the protecting host and the plaintext key
9
+ never sits in the service's environment block (readable by any local admin). At startup the service
10
+ account ``CryptUnprotectData``s the file back to the base64 key.
11
+
12
+ DPAPI is **Windows-only**. Every entry point raises :class:`DpapiUnavailable` elsewhere so callers
13
+ degrade gracefully to the env-var key — this module never imports anything Windows-specific at module
14
+ load, so it imports cleanly on Linux/macOS (CI lint leg) too. The ``ctypes.windll`` calls live behind
15
+ ``sys.platform != "win32"`` guards; mypy treats the code after the guard as unreachable off Windows
16
+ (mirrors :mod:`messagefoundry.console.service_control`), so it type-checks on the Linux CI leg.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ctypes
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ # CryptProtectData flags. LOCAL_MACHINE: any principal on THIS machine can unprotect — required so the
26
+ # low-privilege *service account* (not just the installing admin) can read the key. UI_FORBIDDEN: never
27
+ # raise a prompt (the engine runs headless under a service).
28
+ _CRYPTPROTECT_UI_FORBIDDEN = 0x01
29
+ _CRYPTPROTECT_LOCAL_MACHINE = 0x04
30
+
31
+
32
+ class DpapiUnavailable(RuntimeError):
33
+ """DPAPI was requested off Windows — there is no ``CryptProtectData`` to call."""
34
+
35
+
36
+ class DpapiError(RuntimeError):
37
+ """A DPAPI operation (protect/unprotect or the backing file I/O) failed."""
38
+
39
+
40
+ def dpapi_available() -> bool:
41
+ """Whether DPAPI can be used here (Windows only)."""
42
+ return sys.platform == "win32"
43
+
44
+
45
+ class _DataBlob(ctypes.Structure):
46
+ """The Win32 ``DATA_BLOB`` (cbData + pbData) passed to/from the CryptProtectData API."""
47
+
48
+ _fields_ = (("cbData", ctypes.c_uint32), ("pbData", ctypes.POINTER(ctypes.c_char)))
49
+
50
+
51
+ def _to_blob(data: bytes) -> tuple[_DataBlob, ctypes.Array[ctypes.c_char]]:
52
+ # Return the buffer alongside the blob so the caller keeps it referenced for the call's duration
53
+ # (the blob only borrows the pointer; if the buffer is GC'd mid-call the read is use-after-free).
54
+ buf = ctypes.create_string_buffer(data, len(data))
55
+ blob = _DataBlob(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
56
+ return blob, buf
57
+
58
+
59
+ def dpapi_protect(secret: bytes, *, machine_scope: bool = True) -> bytes:
60
+ """DPAPI-encrypt ``secret``. ``machine_scope`` (default) ties it to the machine so the service
61
+ account can decrypt; False ties it to the current user only. Raises :class:`DpapiUnavailable`
62
+ off Windows, :class:`DpapiError` on a Win32 failure."""
63
+ if sys.platform != "win32":
64
+ raise DpapiUnavailable("DPAPI (CryptProtectData) is only available on Windows")
65
+ crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
66
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
67
+ in_blob, _keep = _to_blob(secret)
68
+ out_blob = _DataBlob()
69
+ flags = _CRYPTPROTECT_UI_FORBIDDEN | (_CRYPTPROTECT_LOCAL_MACHINE if machine_scope else 0)
70
+ ok = crypt32.CryptProtectData(
71
+ ctypes.byref(in_blob), None, None, None, None, flags, ctypes.byref(out_blob)
72
+ )
73
+ if not ok:
74
+ raise DpapiError(f"CryptProtectData failed (Win32 error {ctypes.get_last_error()})")
75
+ try:
76
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
77
+ finally:
78
+ kernel32.LocalFree(out_blob.pbData)
79
+
80
+
81
+ def dpapi_unprotect(blob: bytes) -> bytes:
82
+ """DPAPI-decrypt a ciphertext produced by :func:`dpapi_protect` (on this machine/account).
83
+ Raises :class:`DpapiUnavailable` off Windows, :class:`DpapiError` on a Win32 failure (wrong
84
+ machine/account, or a corrupt/foreign blob)."""
85
+ if sys.platform != "win32":
86
+ raise DpapiUnavailable("DPAPI (CryptUnprotectData) is only available on Windows")
87
+ crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
88
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
89
+ in_blob, _keep = _to_blob(blob)
90
+ out_blob = _DataBlob()
91
+ ok = crypt32.CryptUnprotectData(
92
+ ctypes.byref(in_blob),
93
+ None,
94
+ None,
95
+ None,
96
+ None,
97
+ _CRYPTPROTECT_UI_FORBIDDEN,
98
+ ctypes.byref(out_blob),
99
+ )
100
+ if not ok:
101
+ raise DpapiError(
102
+ f"CryptUnprotectData failed (Win32 error {ctypes.get_last_error()}); the key file must be "
103
+ "unprotected on the same machine that protected it"
104
+ )
105
+ try:
106
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
107
+ finally:
108
+ kernel32.LocalFree(out_blob.pbData)
109
+
110
+
111
+ def protect_key_to_file(key_b64: str, path: Path, *, machine_scope: bool = True) -> None:
112
+ """DPAPI-protect a base64 store key and write the ciphertext to ``path`` (raises on non-Windows
113
+ or a write failure). The caller should restrict the file's ACL afterwards (``_secure_file``)."""
114
+ blob = dpapi_protect(key_b64.strip().encode("ascii"), machine_scope=machine_scope)
115
+ try:
116
+ path.write_bytes(blob)
117
+ except OSError as exc:
118
+ raise DpapiError(f"cannot write protected key file {path}: {exc}") from exc
119
+
120
+
121
+ def load_protected_key(path: str | Path) -> str:
122
+ """Read and DPAPI-decrypt a key file into its base64 store key. Raises :class:`DpapiUnavailable`
123
+ off Windows or :class:`DpapiError` if the file is missing/unreadable/not decryptable here."""
124
+ p = Path(path)
125
+ try:
126
+ blob = p.read_bytes()
127
+ except OSError as exc:
128
+ raise DpapiError(f"cannot read encryption_key_file {p}: {exc}") from exc
129
+ return dpapi_unprotect(blob).decode("ascii").strip()
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Durable message store + queue (SQLite WAL, transactional inbox/outbox).
4
+
5
+ The store *is* the queue: persisting an inbound message and its per-destination outbox
6
+ rows in one transaction buys at-least-once delivery, retries, and replay without a
7
+ separate broker. See :mod:`messagefoundry.store.store` and docs/ARCHITECTURE.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from messagefoundry.store.base import (
13
+ AdminStore,
14
+ AuditStore,
15
+ AuthStore,
16
+ QueueStore,
17
+ Row,
18
+ Store,
19
+ StoreLifecycle,
20
+ open_store,
21
+ sqlite_settings,
22
+ )
23
+ from messagefoundry.store.store import (
24
+ MessageStatus,
25
+ MessageStore,
26
+ OutboxItem,
27
+ OutboxStatus,
28
+ Stage,
29
+ )
30
+
31
+ __all__ = [
32
+ "AdminStore",
33
+ "AuditStore",
34
+ "AuthStore",
35
+ "MessageStatus",
36
+ "MessageStore",
37
+ "OutboxItem",
38
+ "OutboxStatus",
39
+ "QueueStore",
40
+ "Row",
41
+ "Stage",
42
+ "Store",
43
+ "StoreLifecycle",
44
+ "open_store",
45
+ "sqlite_settings",
46
+ ]
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Off-box audit tee — the single PHI-redaction path shared by every store backend (sec-offbox-log).
4
+
5
+ Each store backend (SQLite, Postgres, SQL Server) calls :func:`emit_audit_tee` immediately after a
6
+ ``record_audit`` row is durably committed, so a **PHI-safe metadata** copy of the audit record is
7
+ shipped off-box via the ``messagefoundry.audit`` logger — which propagates to the root stdout +
8
+ optional syslog/SIEM forwarder configured by :mod:`messagefoundry.logging_setup`. So the audit trail
9
+ survives a host/DB compromise (ASVS 16.x).
10
+
11
+ One helper means there is exactly **one** place the off-box PHI-redaction guarantee lives, identical
12
+ across all three backends — not three copies that could drift.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+
20
+ from messagefoundry.redaction import safe_text
21
+
22
+ __all__ = ["audit_logger", "emit_audit_tee"]
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ # Pinned to INFO so audit evidence forwards regardless of the deployment's general log level: the
27
+ # logging_setup root handlers are NOTSET, so a record's only level gate is this logger's own level.
28
+ # It propagates to those root handlers, so no logging_setup change is needed to reach the forwarder.
29
+ audit_logger = logging.getLogger("messagefoundry.audit")
30
+ audit_logger.setLevel(logging.INFO)
31
+
32
+
33
+ def emit_audit_tee(
34
+ *,
35
+ action: str,
36
+ actor: str | None,
37
+ channel_id: str | None,
38
+ detail: str | None,
39
+ ts: float,
40
+ ) -> None:
41
+ """Tee a just-persisted ``audit_log`` record off-box as PHI-safe metadata (sec-offbox-log, ASVS
42
+ 16.x). Emits actor / action / channel / timestamp plus a **redacted** ``detail`` to the
43
+ ``messagefoundry.audit`` logger.
44
+
45
+ **Never emits a raw message body.** ``detail`` can embed raw HL7 fragments from an exception
46
+ message (it is a cipher column at rest for exactly that reason), so it is run through
47
+ :func:`~messagefoundry.redaction.safe_text` — which scrubs HL7-shaped spans and bounds length —
48
+ before it leaves the process. The handler-level ``RedactionFilter`` re-scrubs as a backstop, but
49
+ redacting here keeps the off-box guarantee independent of handler config and **identical across
50
+ every backend**.
51
+
52
+ Best-effort: a logging failure must never fail the audit write (already committed), so it is
53
+ caught and logged, not raised. Callers invoke this **after commit** and **outside any write
54
+ lock/transaction**, so a synchronous syslog send can't block the event loop under a lock."""
55
+ record = {
56
+ "event": "audit",
57
+ "ts": ts,
58
+ "action": action,
59
+ "actor": actor,
60
+ "channel_id": channel_id,
61
+ # PHI chokepoint: redact HL7-shaped content + bound length before it ships off-box.
62
+ "detail": safe_text(detail) if detail else None,
63
+ }
64
+ try:
65
+ audit_logger.info(json.dumps(record, ensure_ascii=False))
66
+ except Exception: # noqa: BLE001 — the audit row is durable; the off-box tee is best-effort
67
+ log.warning("off-box audit tee failed for action=%s", action, exc_info=True)