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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Backend-agnostic store interface + construction seam.
|
|
4
|
+
|
|
5
|
+
The engine and API depend on the store **protocols**, not on a concrete backend, so adding a new
|
|
6
|
+
backend (SQL Server, Postgres, …) only means implementing these methods and registering it in
|
|
7
|
+
:func:`open_store`. Today the sole backend is the SQLite :class:`~messagefoundry.store.store.MessageStore`.
|
|
8
|
+
|
|
9
|
+
The contract is **segregated by concern** so each consumer depends only on the slice it uses
|
|
10
|
+
(interface segregation — see docs/ARCHITECTURE.md §"Architectural standard"):
|
|
11
|
+
|
|
12
|
+
* :class:`QueueStore` — the message inbox/outbox lifecycle + reads + store health. The engine,
|
|
13
|
+
the :class:`~messagefoundry.pipeline.wiring_runner.RegistryRunner`, and the message routes use this.
|
|
14
|
+
* :class:`AuditStore` — the audit log + PHI-view trail.
|
|
15
|
+
* :class:`AuthStore` — users, roles, sessions, AD-group maps. Only :class:`AuthService` uses this,
|
|
16
|
+
and it can no longer reach the queue/message methods.
|
|
17
|
+
* :class:`Store` — the composite a backend implements and :func:`open_store` returns.
|
|
18
|
+
|
|
19
|
+
Read methods return :class:`Row` — a minimal protocol (key access + ``keys()``) satisfied by both
|
|
20
|
+
``aiosqlite.Row`` and a plain ``dict``, so a non-SQLite backend can return its own row mapping without
|
|
21
|
+
the callers caring.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from messagefoundry.config.models import RetryPolicy
|
|
31
|
+
from messagefoundry.config.settings import SqliteSync, StoreBackend, StoreSettings
|
|
32
|
+
from messagefoundry.store.crypto import make_cipher
|
|
33
|
+
from messagefoundry.store.keyprovider import resolve_key_provider
|
|
34
|
+
from messagefoundry.store.store import (
|
|
35
|
+
CapturedResponse,
|
|
36
|
+
ConnectionMetrics,
|
|
37
|
+
DbStatus,
|
|
38
|
+
MessageStatus,
|
|
39
|
+
MessageStore,
|
|
40
|
+
OutboxItem,
|
|
41
|
+
SessionRecord,
|
|
42
|
+
Stage,
|
|
43
|
+
UserRecord,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AdminStore",
|
|
48
|
+
"AuditStore",
|
|
49
|
+
"AuthStore",
|
|
50
|
+
"QueueStore",
|
|
51
|
+
"Row",
|
|
52
|
+
"Store",
|
|
53
|
+
"StoreLifecycle",
|
|
54
|
+
"open_store",
|
|
55
|
+
"sqlite_settings",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Row(Protocol):
|
|
60
|
+
"""A read result: key access + ``keys()`` (satisfied by ``aiosqlite.Row`` and ``dict``)."""
|
|
61
|
+
|
|
62
|
+
def __getitem__(self, key: str) -> Any: ...
|
|
63
|
+
def keys(self) -> Iterable[str]: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StoreLifecycle(Protocol):
|
|
67
|
+
"""Open-store handle basics shared by every backend."""
|
|
68
|
+
|
|
69
|
+
path: str
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class QueueStore(StoreLifecycle, Protocol):
|
|
75
|
+
"""The durable message inbox/outbox queue — the contract the engine + message routes use.
|
|
76
|
+
|
|
77
|
+
Covers the transactional write path, the per-destination delivery worker, recovery/replay, the
|
|
78
|
+
read helpers the API/console render, and store-health/metrics. Deliberately excludes auth and the
|
|
79
|
+
audit log so a queue consumer cannot reach them.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
#: Whether this backend implements the staged ingress pipeline (``enqueue_ingress``/``handoff``).
|
|
83
|
+
#: ``False`` backends (e.g. SQL Server, gated on BACKLOG #1) are rejected at engine start rather
|
|
84
|
+
#: than trapping the first received message in a ``NotImplementedError``.
|
|
85
|
+
supports_ingest_stage: bool
|
|
86
|
+
|
|
87
|
+
#: Whether this backend can capture request/response replies (ADR 0013: the ``response`` table +
|
|
88
|
+
#: :meth:`complete_with_response`). ``True`` on SQLite/Postgres/SQL Server; a backend returning
|
|
89
|
+
#: ``False`` makes the runner reject a capturing outbound at start (fail-closed) rather than drop
|
|
90
|
+
#: captures.
|
|
91
|
+
supports_response_capture: bool
|
|
92
|
+
|
|
93
|
+
# --- write path ----------------------------------------------------------
|
|
94
|
+
async def enqueue_message(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
channel_id: str,
|
|
98
|
+
raw: str,
|
|
99
|
+
deliveries: Sequence[tuple[str, str]],
|
|
100
|
+
control_id: str | None = None,
|
|
101
|
+
message_type: str | None = None,
|
|
102
|
+
source_type: str | None = None,
|
|
103
|
+
summary: str | None = None,
|
|
104
|
+
metadata: str | None = None,
|
|
105
|
+
now: float | None = None,
|
|
106
|
+
) -> str: ...
|
|
107
|
+
|
|
108
|
+
async def record_received(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
channel_id: str,
|
|
112
|
+
raw: str,
|
|
113
|
+
status: MessageStatus,
|
|
114
|
+
error: str | None = None,
|
|
115
|
+
control_id: str | None = None,
|
|
116
|
+
message_type: str | None = None,
|
|
117
|
+
source_type: str | None = None,
|
|
118
|
+
summary: str | None = None,
|
|
119
|
+
metadata: str | None = None,
|
|
120
|
+
now: float | None = None,
|
|
121
|
+
) -> str: ...
|
|
122
|
+
|
|
123
|
+
async def enqueue_ingress(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
channel_id: str,
|
|
127
|
+
raw: str,
|
|
128
|
+
control_id: str | None = None,
|
|
129
|
+
message_type: str | None = None,
|
|
130
|
+
source_type: str | None = None,
|
|
131
|
+
summary: str | None = None,
|
|
132
|
+
metadata: str | None = None,
|
|
133
|
+
now: float | None = None,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Durably persist a freshly-received raw message to the ingress stage (status ``RECEIVED`` +
|
|
136
|
+
one ``stage='ingress'`` queue row) in one transaction — the staged pipeline's ACK-on-receipt
|
|
137
|
+
boundary. The inbound may be ACKed once this returns. Returns the message id."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
async def handoff(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
ingress_id: str,
|
|
144
|
+
message_id: str,
|
|
145
|
+
channel_id: str,
|
|
146
|
+
deliveries: Sequence[tuple[str, str]],
|
|
147
|
+
disposition: MessageStatus,
|
|
148
|
+
now: float | None = None,
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Advance a message from ingress to outbound in one transaction (claim→produce→complete):
|
|
151
|
+
consume the in-flight ingress row, insert one outbound row per delivery, set the post-router
|
|
152
|
+
``disposition`` (``ROUTED``/``FILTERED``/``UNROUTED``). Idempotent against worker restart —
|
|
153
|
+
returns ``False`` (a no-op) if the ingress row was already consumed by a prior run. The Step-A
|
|
154
|
+
combined router+transform primitive; the split pipeline uses :meth:`route_handoff` +
|
|
155
|
+
:meth:`transform_handoff` instead."""
|
|
156
|
+
...
|
|
157
|
+
|
|
158
|
+
async def route_handoff(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
ingress_id: str,
|
|
162
|
+
message_id: str,
|
|
163
|
+
channel_id: str,
|
|
164
|
+
handlers: Sequence[tuple[str, str]],
|
|
165
|
+
disposition: MessageStatus,
|
|
166
|
+
now: float | None = None,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Advance a message from the ingress stage to the **routed** stage in one transaction (the
|
|
169
|
+
router half of the split pipeline, ADR 0001 Step B): consume the in-flight ingress row, insert
|
|
170
|
+
one ``stage='routed'`` row per selected handler (each ``(handler_name, raw_payload)``), set the
|
|
171
|
+
intermediate ``disposition`` (``ROUTED`` with handlers, ``UNROUTED`` with none). Idempotent
|
|
172
|
+
against worker restart — ``False`` if the ingress row was already consumed."""
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
async def transform_handoff(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
routed_id: str,
|
|
179
|
+
message_id: str,
|
|
180
|
+
channel_id: str,
|
|
181
|
+
deliveries: Sequence[tuple[str, str]],
|
|
182
|
+
state_ops: Sequence[tuple[str, str, Any]] = (),
|
|
183
|
+
now: float | None = None,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""Advance one handler assignment from the **routed** stage to outbound in one transaction (the
|
|
186
|
+
transform half of the split pipeline, ADR 0001 Step B): consume the in-flight routed row,
|
|
187
|
+
insert one outbound row per delivery, **apply each declared state write** (``state_ops``:
|
|
188
|
+
``(namespace, key, value)`` upserts, ADR 0005), and let the finalizer recompute the terminal
|
|
189
|
+
disposition (this method never writes ``messages.status`` directly). The state writes commit
|
|
190
|
+
atomically with the outbound rows, so a crash before commit leaves no state and a re-run applies
|
|
191
|
+
them exactly-once (preserving the pure-re-run invariant). Idempotent against worker restart —
|
|
192
|
+
``False`` if the routed row was already consumed."""
|
|
193
|
+
...
|
|
194
|
+
|
|
195
|
+
def state_view(self) -> Mapping[tuple[str, str], Any]:
|
|
196
|
+
"""A read-only view of the engine-maintained transform-state read-through cache (ADR 0005):
|
|
197
|
+
``{(namespace, key): decoded_value}``. The runner publishes it around each router/transform run
|
|
198
|
+
so a Handler's synchronous ``state_get(...)`` resolves. Reflects writes as they commit."""
|
|
199
|
+
...
|
|
200
|
+
|
|
201
|
+
# --- reference sets (ADR 0006 Tier 1) ------------------------------------
|
|
202
|
+
def reference_view(self) -> Mapping[str, Mapping[str, Any]]:
|
|
203
|
+
"""A read-only view of the active reference snapshots (ADR 0006): ``{name: {key: value}}``. The
|
|
204
|
+
runner publishes it around each router/transform run so ``reference("name").get(key)`` resolves.
|
|
205
|
+
Swaps in a new snapshot only after a sync commits."""
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
async def write_reference_snapshot(
|
|
209
|
+
self, *, name: str, version: str, rows: Mapping[str, Any]
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Materialize a new reference snapshot for ``name`` and atomically make it active (ADR 0006):
|
|
212
|
+
one transaction replaces the set's rows and flips the active version; the read cache swaps only
|
|
213
|
+
after commit, so a failed sync leaves the last-good snapshot live."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
async def converge_reference_cache(self) -> list[str]:
|
|
217
|
+
"""Refresh this node's in-process reference read cache from the shared store (Track B Step 6).
|
|
218
|
+
|
|
219
|
+
The follower read-through: re-loads any set whose authoritative active version (in the shared
|
|
220
|
+
store) is newer than the version currently reflected in this handle's cache, **without**
|
|
221
|
+
re-reading the external source. Returns the names of the sets actually refreshed (``[]`` when
|
|
222
|
+
nothing changed). Multi-node Postgres implements it for real; single-node backends (SQLite,
|
|
223
|
+
SQL Server) return ``[]`` (a single node is the sole writer, so its cache is always current)."""
|
|
224
|
+
...
|
|
225
|
+
|
|
226
|
+
async def converge_state_cache(self) -> list[str]:
|
|
227
|
+
"""Refresh this node's in-process transform-STATE read cache from the shared store (Track B
|
|
228
|
+
Step 6b).
|
|
229
|
+
|
|
230
|
+
The follower read-through for ADR 0005 state: re-reads any namespace whose per-namespace version
|
|
231
|
+
(in the shared store) is newer than the version currently reflected in this handle's cache, so a
|
|
232
|
+
sibling node's state write reaches every node. Returns the namespace names actually refreshed
|
|
233
|
+
(``[]`` when nothing changed). Multi-node Postgres implements it for real; single-node backends
|
|
234
|
+
(SQLite, SQL Server) return ``[]`` (a single node is the sole writer, so its cache is always
|
|
235
|
+
current)."""
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
def enable_state_convergence(self) -> None:
|
|
239
|
+
"""Turn on per-namespace state-version bumping for cross-node convergence (Track B Step 6b). The
|
|
240
|
+
engine calls this only in a cluster (``coordinator.is_clustered()``) BEFORE workers start, so a
|
|
241
|
+
sibling's :meth:`converge_state_cache` sees every write. Single-node never calls it → no version
|
|
242
|
+
writes → byte-identical. A no-op on backends without cross-node convergence (SQLite, SQL Server)."""
|
|
243
|
+
...
|
|
244
|
+
|
|
245
|
+
# --- delivery worker path ------------------------------------------------
|
|
246
|
+
async def claim_ready(
|
|
247
|
+
self,
|
|
248
|
+
limit: int = 10,
|
|
249
|
+
now: float | None = None,
|
|
250
|
+
*,
|
|
251
|
+
stage: str = Stage.OUTBOUND.value,
|
|
252
|
+
channel_id: str | None = None,
|
|
253
|
+
destination_name: str | None = None,
|
|
254
|
+
) -> list[OutboxItem]: ...
|
|
255
|
+
|
|
256
|
+
async def claim_next_fifo(
|
|
257
|
+
self,
|
|
258
|
+
name: str,
|
|
259
|
+
now: float | None = None,
|
|
260
|
+
*,
|
|
261
|
+
stage: str = Stage.OUTBOUND.value,
|
|
262
|
+
owner: str | None = None,
|
|
263
|
+
) -> OutboxItem | None:
|
|
264
|
+
"""Claim the single oldest *due* pending row for one lane at ``stage`` (strict FIFO; the head
|
|
265
|
+
blocks the lane while it backs off). The lane key is stage-aware: ``destination_name`` for
|
|
266
|
+
outbound, ``channel_id`` for ingress. ``None`` when nothing is pending or the head isn't due.
|
|
267
|
+
|
|
268
|
+
``owner`` is this node's cluster identity (Track B Step 5 lane ownership): ``None`` single-node
|
|
269
|
+
(the byte-identical path; SQLite/SQL Server always ignore it), or the coordinator's node_id
|
|
270
|
+
when clustered, gating the claim by an atomic per-lane lease so a FIFO lane is processed by
|
|
271
|
+
exactly one node at a time and strict per-lane FIFO holds across nodes."""
|
|
272
|
+
...
|
|
273
|
+
|
|
274
|
+
async def mark_done(self, outbox_id: str, now: float | None = None) -> None: ...
|
|
275
|
+
|
|
276
|
+
async def complete_with_response(
|
|
277
|
+
self,
|
|
278
|
+
outbox_id: str,
|
|
279
|
+
*,
|
|
280
|
+
body: str,
|
|
281
|
+
outcome: str,
|
|
282
|
+
detail: str | None = None,
|
|
283
|
+
reingress_to: str | None = None,
|
|
284
|
+
now: float | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Mark one outbound row delivered **and** persist the partner's captured reply (ADR 0013) in
|
|
287
|
+
one atomic transaction — :meth:`mark_done` plus an immutable ``response`` row keyed
|
|
288
|
+
``(message_id, destination_name, response_seq)``. The delivery worker calls **exactly one** of
|
|
289
|
+
this or :meth:`mark_done` per successful delivery (the capture XOR). The ``response`` table is
|
|
290
|
+
invisible to disposition (the finalizer scans ``queue`` only), so a captured delivery finalizes
|
|
291
|
+
``PROCESSED`` exactly as a one-way one does.
|
|
292
|
+
|
|
293
|
+
When ``reingress_to`` is set (Increment 2), the same transaction *also* inserts a drainable
|
|
294
|
+
``Stage.RESPONSE`` work-row on the named loopback inbound's lane (a token referencing the
|
|
295
|
+
artifact) so the reply is re-ingressed; ``None`` is byte-identical to Increment 1 (no work-row)."""
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
async def correlate_response(self, message_id: str) -> list[CapturedResponse]:
|
|
299
|
+
"""Every captured reply for ``message_id`` (ADR 0013), ordered by destination then
|
|
300
|
+
``response_seq`` (latest seq per destination = the authoritative reply). The PHI read surface
|
|
301
|
+
behind the audited, body-gated ``GET /messages/{id}/responses`` route."""
|
|
302
|
+
...
|
|
303
|
+
|
|
304
|
+
async def ingress_handoff(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
response_row_id: str,
|
|
308
|
+
loopback_channel_id: str,
|
|
309
|
+
correlation_depth_cap: int,
|
|
310
|
+
control_id: str | None,
|
|
311
|
+
message_type: str | None,
|
|
312
|
+
summary: str | None,
|
|
313
|
+
peek_failed: bool = False,
|
|
314
|
+
now: float | None = None,
|
|
315
|
+
) -> bool:
|
|
316
|
+
"""Consume one INFLIGHT ``Stage.RESPONSE`` work-row and produce the re-ingressed message+ingress
|
|
317
|
+
row in one transaction (ADR 0013 Increment 2) — the re-ingress edge. A guarded ``DELETE`` of the
|
|
318
|
+
work-row is the exactly-once commit, so a committed run is an idempotent no-op (``False``). The
|
|
319
|
+
re-ingress worker peeks the loopback body and passes the derived metadata in. Returns ``True`` if
|
|
320
|
+
this call performed the handoff."""
|
|
321
|
+
...
|
|
322
|
+
|
|
323
|
+
async def response_body_for_work_row(self, response_row_id: str) -> str | None:
|
|
324
|
+
"""The decrypted artifact body a ``Stage.RESPONSE`` work-row references (ADR 0013 Increment 2) —
|
|
325
|
+
read by the re-ingress worker to HL7-peek the reply (in ``pipeline/``) before
|
|
326
|
+
:meth:`ingress_handoff`. ``None`` if the row/artifact is gone."""
|
|
327
|
+
...
|
|
328
|
+
|
|
329
|
+
async def mark_failed(
|
|
330
|
+
self, outbox_id: str, error: str, retry: RetryPolicy, now: float | None = None
|
|
331
|
+
) -> None: ...
|
|
332
|
+
|
|
333
|
+
async def dead_letter_now(self, outbox_id: str, error: str, now: float | None = None) -> None:
|
|
334
|
+
"""Force one outbox row terminal (``DEAD``) immediately — **fail-fast**, no retry consumed
|
|
335
|
+
and no backoff. For deliveries that can never succeed as-is and must not hold the FIFO lane:
|
|
336
|
+
a permanent partner reject (``AR``), an internal/code error under the error-and-continue
|
|
337
|
+
policy, or an undecryptable payload. Replayable via the dead-letter API like any dead row.
|
|
338
|
+
Contrast :meth:`mark_failed`, which reschedules with backoff (and only dead-letters once a
|
|
339
|
+
finite ``max_attempts`` is exhausted)."""
|
|
340
|
+
...
|
|
341
|
+
|
|
342
|
+
# --- recovery / replay ---------------------------------------------------
|
|
343
|
+
async def pending_depth(
|
|
344
|
+
self, name: str, *, stage: str = Stage.OUTBOUND.value
|
|
345
|
+
) -> tuple[int, float | None]:
|
|
346
|
+
"""Backlog shape for one lane at ``stage``: ``(pending_count, oldest_created_at)`` — the number
|
|
347
|
+
of rows still waiting and the enqueue time of the oldest (``None`` when empty). Lane key is
|
|
348
|
+
stage-aware (``destination_name`` outbound, ``channel_id`` ingress). The workers use this to
|
|
349
|
+
raise a ``queue_buildup`` alert when a lane stops draining. Cheap: a single COUNT + MIN."""
|
|
350
|
+
...
|
|
351
|
+
|
|
352
|
+
async def reset_stale_inflight(
|
|
353
|
+
self, now: float | None = None, *, stage: str | None = None
|
|
354
|
+
) -> int:
|
|
355
|
+
"""Return ``inflight`` rows (claimed before a crash) to ``pending``. ``stage=None`` (default)
|
|
356
|
+
recovers every stage in one pass — the right startup behavior; pass a stage to scope it."""
|
|
357
|
+
...
|
|
358
|
+
|
|
359
|
+
async def dead_letter_missing_destinations(
|
|
360
|
+
self, valid_names: set[str], now: float | None = None
|
|
361
|
+
) -> int: ...
|
|
362
|
+
|
|
363
|
+
async def dead_letter_missing_handlers(
|
|
364
|
+
self, valid_names: set[str], now: float | None = None
|
|
365
|
+
) -> int:
|
|
366
|
+
"""Dead-letter non-terminal **routed** rows whose ``handler_name`` left the registry (a removed
|
|
367
|
+
handler no transform worker can run). The routed-stage parallel of
|
|
368
|
+
:meth:`dead_letter_missing_destinations`; call once at startup. Returns the rows killed."""
|
|
369
|
+
...
|
|
370
|
+
|
|
371
|
+
async def replay(self, message_id: str, now: float | None = None) -> int: ...
|
|
372
|
+
|
|
373
|
+
async def replay_dead(
|
|
374
|
+
self,
|
|
375
|
+
*,
|
|
376
|
+
channel_id: str | None = None,
|
|
377
|
+
destination_name: str | None = None,
|
|
378
|
+
now: float | None = None,
|
|
379
|
+
) -> int: ...
|
|
380
|
+
|
|
381
|
+
async def cancel_queued(
|
|
382
|
+
self,
|
|
383
|
+
channel_id: str | None,
|
|
384
|
+
destination_name: str,
|
|
385
|
+
*,
|
|
386
|
+
top_only: bool = False,
|
|
387
|
+
now: float | None = None,
|
|
388
|
+
) -> int: ...
|
|
389
|
+
|
|
390
|
+
# --- read helpers (API / console) ----------------------------------------
|
|
391
|
+
# Row sequences are returned as Sequence[Row] (covariant) so a backend may return its own row
|
|
392
|
+
# type (e.g. aiosqlite.Row) — list[Row] would be invariant and reject that.
|
|
393
|
+
async def get_message(self, message_id: str) -> dict[str, Any] | None: ...
|
|
394
|
+
|
|
395
|
+
async def list_messages(
|
|
396
|
+
self,
|
|
397
|
+
*,
|
|
398
|
+
channel_id: str | None = None,
|
|
399
|
+
status: str | None = None,
|
|
400
|
+
message_type: str | None = None,
|
|
401
|
+
control_id: str | None = None,
|
|
402
|
+
limit: int = 50,
|
|
403
|
+
offset: int = 0,
|
|
404
|
+
allowed_channels: Sequence[str] | None = None,
|
|
405
|
+
) -> Sequence[Row]: ...
|
|
406
|
+
|
|
407
|
+
async def count_messages(
|
|
408
|
+
self,
|
|
409
|
+
*,
|
|
410
|
+
channel_id: str | None = None,
|
|
411
|
+
status: str | None = None,
|
|
412
|
+
message_type: str | None = None,
|
|
413
|
+
control_id: str | None = None,
|
|
414
|
+
allowed_channels: Sequence[str] | None = None,
|
|
415
|
+
) -> int: ...
|
|
416
|
+
|
|
417
|
+
async def list_dead(
|
|
418
|
+
self,
|
|
419
|
+
*,
|
|
420
|
+
channel_id: str | None = None,
|
|
421
|
+
destination_name: str | None = None,
|
|
422
|
+
limit: int = 50,
|
|
423
|
+
offset: int = 0,
|
|
424
|
+
allowed_channels: Sequence[str] | None = None,
|
|
425
|
+
) -> Sequence[Row]: ...
|
|
426
|
+
|
|
427
|
+
async def count_dead(
|
|
428
|
+
self,
|
|
429
|
+
*,
|
|
430
|
+
channel_id: str | None = None,
|
|
431
|
+
destination_name: str | None = None,
|
|
432
|
+
allowed_channels: Sequence[str] | None = None,
|
|
433
|
+
) -> int: ...
|
|
434
|
+
|
|
435
|
+
async def outbox_for(self, message_id: str) -> Sequence[Row]: ...
|
|
436
|
+
|
|
437
|
+
async def outbox_payloads_for(self, message_id: str) -> Sequence[Row]:
|
|
438
|
+
"""Like :meth:`outbox_for` but the rows also carry the **decrypted transformed ``payload``**
|
|
439
|
+
(PHI body) per destination — the #14 parity-comparison read path. Kept separate from
|
|
440
|
+
:meth:`outbox_for` so the metadata-only message-detail view never decrypts bodies; the API
|
|
441
|
+
gates this on ``MESSAGES_VIEW_RAW`` and audits every access."""
|
|
442
|
+
...
|
|
443
|
+
|
|
444
|
+
async def events_for(self, message_id: str) -> Sequence[Row]: ...
|
|
445
|
+
|
|
446
|
+
async def stats(self) -> dict[str, int]: ...
|
|
447
|
+
|
|
448
|
+
async def in_pipeline_depth(self) -> int:
|
|
449
|
+
"""Count of NOT-DONE rows (status ``pending``|``inflight``) across **every** stage
|
|
450
|
+
(ingress + routed + outbound) — a whole-pipeline drain gauge, vs :meth:`stats` which sees only
|
|
451
|
+
the outbound stage. Lets a consumer tell a true drain from a stalled router/transform."""
|
|
452
|
+
...
|
|
453
|
+
|
|
454
|
+
# --- at-rest key rotation (PHI.md §3, ASVS 11.2.2) -----------------------
|
|
455
|
+
async def reencrypt_to_active(self, *, batch: int = 500) -> int: ...
|
|
456
|
+
|
|
457
|
+
# --- retention / purge + maintenance (PHI.md §8) -------------------------
|
|
458
|
+
async def purge_message_bodies(self, *, older_than: float, now: float | None = None) -> int: ...
|
|
459
|
+
|
|
460
|
+
async def purge_dead_letters(self, *, older_than: float, now: float | None = None) -> int: ...
|
|
461
|
+
|
|
462
|
+
async def purge_state(self, *, older_than: float, now: float | None = None) -> int:
|
|
463
|
+
"""Delete transform-state entries (ADR 0005) last written before ``older_than`` (age-based
|
|
464
|
+
retention). Returns the number purged. Off unless ``[retention].state_max_age_days`` is set."""
|
|
465
|
+
...
|
|
466
|
+
|
|
467
|
+
async def wal_checkpoint(self) -> None: ...
|
|
468
|
+
|
|
469
|
+
async def vacuum(self) -> None: ...
|
|
470
|
+
|
|
471
|
+
# --- store health / metrics ----------------------------------------------
|
|
472
|
+
async def db_status(self) -> DbStatus: ...
|
|
473
|
+
|
|
474
|
+
async def integrity_check(self) -> tuple[bool, str]: ...
|
|
475
|
+
|
|
476
|
+
async def connection_metrics(
|
|
477
|
+
self, *, since: float, now: float | None = None, rate_window: float = 60.0
|
|
478
|
+
) -> ConnectionMetrics: ...
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class AuditStore(Protocol):
|
|
482
|
+
"""The audit log + PHI-view trail (tamper-evident hash chain)."""
|
|
483
|
+
|
|
484
|
+
async def record_view(
|
|
485
|
+
self, message_id: str, *, actor: str | None = None, now: float | None = None
|
|
486
|
+
) -> None: ...
|
|
487
|
+
|
|
488
|
+
async def record_audit(
|
|
489
|
+
self,
|
|
490
|
+
action: str,
|
|
491
|
+
*,
|
|
492
|
+
actor: str | None = None,
|
|
493
|
+
channel_id: str | None = None,
|
|
494
|
+
detail: str | None = None,
|
|
495
|
+
now: float | None = None,
|
|
496
|
+
) -> None: ...
|
|
497
|
+
|
|
498
|
+
async def list_audit(self, *, limit: int = 50) -> Sequence[Row]: ...
|
|
499
|
+
|
|
500
|
+
async def security_events_for_user(
|
|
501
|
+
self, username: str, *, limit: int = 100
|
|
502
|
+
) -> Sequence[Row]: ...
|
|
503
|
+
|
|
504
|
+
async def create_pending_approval(
|
|
505
|
+
self,
|
|
506
|
+
*,
|
|
507
|
+
approval_id: str,
|
|
508
|
+
operation: str,
|
|
509
|
+
params: str,
|
|
510
|
+
requester: str,
|
|
511
|
+
requested_at: float,
|
|
512
|
+
expires_at: float | None,
|
|
513
|
+
) -> None: ...
|
|
514
|
+
|
|
515
|
+
async def get_pending_approval(self, approval_id: str) -> Row | None: ...
|
|
516
|
+
|
|
517
|
+
async def list_pending_approvals(self, *, now: float, limit: int = 100) -> Sequence[Row]: ...
|
|
518
|
+
|
|
519
|
+
async def decide_pending_approval(
|
|
520
|
+
self, approval_id: str, *, status: str, approver: str | None, decided_at: float
|
|
521
|
+
) -> bool: ...
|
|
522
|
+
|
|
523
|
+
async def audit_anchor(self) -> tuple[int, str]: ...
|
|
524
|
+
|
|
525
|
+
async def verify_audit_chain(
|
|
526
|
+
self, *, expected_anchor: tuple[int, str] | None = None
|
|
527
|
+
) -> tuple[bool, str | None]: ...
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class AuthStore(Protocol):
|
|
531
|
+
"""Users, roles, sessions, and AD-group mappings — the contract :class:`AuthService` uses.
|
|
532
|
+
|
|
533
|
+
Segregated from the queue/message contract so the auth subsystem cannot reach inbox/outbox rows.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
# --- users ---------------------------------------------------------------
|
|
537
|
+
async def create_user(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
user_id: str,
|
|
541
|
+
username: str,
|
|
542
|
+
auth_provider: str,
|
|
543
|
+
display_name: str | None = None,
|
|
544
|
+
email: str | None = None,
|
|
545
|
+
password_hash: str | None = None,
|
|
546
|
+
must_change_password: bool = False,
|
|
547
|
+
now: float | None = None,
|
|
548
|
+
) -> None: ...
|
|
549
|
+
|
|
550
|
+
async def get_user(self, user_id: str) -> UserRecord | None: ...
|
|
551
|
+
|
|
552
|
+
async def get_user_by_username(self, username: str) -> UserRecord | None: ...
|
|
553
|
+
|
|
554
|
+
async def list_users(self) -> Sequence[UserRecord]: ...
|
|
555
|
+
|
|
556
|
+
async def count_users(self) -> int: ...
|
|
557
|
+
|
|
558
|
+
async def set_password(
|
|
559
|
+
self,
|
|
560
|
+
user_id: str,
|
|
561
|
+
*,
|
|
562
|
+
password_hash: str,
|
|
563
|
+
must_change_password: bool = False,
|
|
564
|
+
now: float | None = None,
|
|
565
|
+
) -> None: ...
|
|
566
|
+
|
|
567
|
+
async def set_user_disabled(
|
|
568
|
+
self, user_id: str, *, disabled: bool, now: float | None = None
|
|
569
|
+
) -> None: ...
|
|
570
|
+
|
|
571
|
+
async def update_user_profile(
|
|
572
|
+
self,
|
|
573
|
+
user_id: str,
|
|
574
|
+
*,
|
|
575
|
+
display_name: str | None,
|
|
576
|
+
email: str | None,
|
|
577
|
+
now: float | None = None,
|
|
578
|
+
) -> None: ...
|
|
579
|
+
|
|
580
|
+
async def delete_user(self, user_id: str) -> None: ...
|
|
581
|
+
|
|
582
|
+
# --- MFA: native TOTP second factor (local accounts, WP-14) --------------
|
|
583
|
+
async def set_totp_secret(
|
|
584
|
+
self, user_id: str, *, secret: str | None, now: float | None = None
|
|
585
|
+
) -> None: ...
|
|
586
|
+
|
|
587
|
+
async def get_totp_secret(self, user_id: str) -> str | None: ...
|
|
588
|
+
|
|
589
|
+
async def enable_totp(
|
|
590
|
+
self, user_id: str, *, recovery_code_hashes: list[str], now: float | None = None
|
|
591
|
+
) -> None: ...
|
|
592
|
+
|
|
593
|
+
async def disable_totp(self, user_id: str, *, now: float | None = None) -> None: ...
|
|
594
|
+
|
|
595
|
+
async def get_recovery_code_hashes(self, user_id: str) -> list[str]: ...
|
|
596
|
+
|
|
597
|
+
async def consume_recovery_code_hash(
|
|
598
|
+
self, user_id: str, code_hash: str, *, now: float | None = None
|
|
599
|
+
) -> bool: ...
|
|
600
|
+
|
|
601
|
+
async def consume_totp_step(self, user_id: str, step: int) -> bool: ...
|
|
602
|
+
|
|
603
|
+
async def record_login_success(self, user_id: str, *, now: float | None = None) -> None: ...
|
|
604
|
+
|
|
605
|
+
async def record_login_failure(
|
|
606
|
+
self,
|
|
607
|
+
user_id: str,
|
|
608
|
+
*,
|
|
609
|
+
failed_attempts: int,
|
|
610
|
+
locked_until: float | None,
|
|
611
|
+
now: float | None = None,
|
|
612
|
+
) -> None: ...
|
|
613
|
+
|
|
614
|
+
# --- roles / AD-group maps -----------------------------------------------
|
|
615
|
+
async def upsert_role(
|
|
616
|
+
self,
|
|
617
|
+
*,
|
|
618
|
+
role_id: str,
|
|
619
|
+
display_name: str,
|
|
620
|
+
description: str | None = None,
|
|
621
|
+
builtin: bool = True,
|
|
622
|
+
) -> None: ...
|
|
623
|
+
|
|
624
|
+
async def list_roles(self) -> Sequence[Row]: ...
|
|
625
|
+
|
|
626
|
+
async def get_user_role_ids(self, user_id: str) -> list[str]: ...
|
|
627
|
+
|
|
628
|
+
async def set_user_roles(
|
|
629
|
+
self,
|
|
630
|
+
user_id: str,
|
|
631
|
+
role_ids: Sequence[str],
|
|
632
|
+
*,
|
|
633
|
+
assigned_by: str | None = None,
|
|
634
|
+
now: float | None = None,
|
|
635
|
+
) -> None: ...
|
|
636
|
+
|
|
637
|
+
async def set_user_channel_scope(
|
|
638
|
+
self, user_id: str, scope_json: str | None, *, now: float | None = None
|
|
639
|
+
) -> None: ...
|
|
640
|
+
|
|
641
|
+
async def roles_for_ad_groups(self, groups: Iterable[str]) -> set[str]: ...
|
|
642
|
+
|
|
643
|
+
async def list_ad_group_role_map(self) -> Sequence[Row]: ...
|
|
644
|
+
|
|
645
|
+
async def set_ad_group_role_map(self, entries: Iterable[tuple[str, str]]) -> None: ...
|
|
646
|
+
|
|
647
|
+
async def channels_for_ad_groups(self, groups: Iterable[str]) -> set[str]: ...
|
|
648
|
+
|
|
649
|
+
async def list_ad_group_scope_map(self) -> Sequence[Row]: ...
|
|
650
|
+
|
|
651
|
+
async def set_ad_group_scope_map(self, entries: Iterable[tuple[str, str]]) -> None: ...
|
|
652
|
+
|
|
653
|
+
# --- sessions ------------------------------------------------------------
|
|
654
|
+
async def create_session(
|
|
655
|
+
self,
|
|
656
|
+
*,
|
|
657
|
+
token_hash: str,
|
|
658
|
+
user_id: str,
|
|
659
|
+
expires_at: float,
|
|
660
|
+
client: str | None = None,
|
|
661
|
+
seed_reauth: bool = True,
|
|
662
|
+
now: float | None = None,
|
|
663
|
+
) -> None: ...
|
|
664
|
+
|
|
665
|
+
async def get_session(self, token_hash: str) -> SessionRecord | None: ...
|
|
666
|
+
|
|
667
|
+
async def list_sessions(
|
|
668
|
+
self, user_id: str, *, now: float | None = None
|
|
669
|
+
) -> list[SessionRecord]: ...
|
|
670
|
+
|
|
671
|
+
async def touch_session(self, token_hash: str, *, now: float | None = None) -> None: ...
|
|
672
|
+
|
|
673
|
+
async def mark_session_reauthed(
|
|
674
|
+
self, token_hash: str, *, now: float | None = None, client: str | None = None
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Refresh the session's step-up freshness (``reauth_at``). When ``client`` is given, also
|
|
677
|
+
re-anchor the session's last-verified client address to it (the new-client-IP risk signal in
|
|
678
|
+
WP-L3-13 uses this so a re-verify from a roamed address clears the forced step-up); a ``None``
|
|
679
|
+
``client`` leaves the stored address unchanged."""
|
|
680
|
+
...
|
|
681
|
+
|
|
682
|
+
async def mark_session_mfa_verified(
|
|
683
|
+
self, token_hash: str, *, now: float | None = None
|
|
684
|
+
) -> None: ...
|
|
685
|
+
|
|
686
|
+
async def revoke_session(self, token_hash: str, *, now: float | None = None) -> None: ...
|
|
687
|
+
|
|
688
|
+
async def revoke_user_sessions(
|
|
689
|
+
self, user_id: str, *, except_token_hash: str | None = None, now: float | None = None
|
|
690
|
+
) -> int: ...
|
|
691
|
+
|
|
692
|
+
async def enforce_session_cap(
|
|
693
|
+
self, user_id: str, *, keep: int, now: float | None = None
|
|
694
|
+
) -> None: ...
|
|
695
|
+
|
|
696
|
+
async def purge_expired_sessions(self, *, now: float | None = None) -> int: ...
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class AdminStore(AuthStore, AuditStore, Protocol):
|
|
700
|
+
"""Auth + audit-log reads — the surface :class:`AuthService` exposes to its admin endpoints.
|
|
701
|
+
|
|
702
|
+
Wider than :class:`AuthStore` because the user-administration routes also read the audit log,
|
|
703
|
+
but still excludes :class:`QueueStore`: the auth subsystem can never reach inbox/outbox rows.
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@runtime_checkable
|
|
708
|
+
class Store(QueueStore, AuditStore, AuthStore, Protocol):
|
|
709
|
+
"""The full store contract — every backend implements all three concerns in one handle.
|
|
710
|
+
|
|
711
|
+
Kept ``runtime_checkable`` so ``isinstance(store, Store)`` can smoke-check a backend. The concerns
|
|
712
|
+
deliberately share one SQLite file/handle (single-file inbox/outbox + audit + auth, no broker);
|
|
713
|
+
the segregation is in the *contract* each consumer depends on, not in the physical store.
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def resolve_active_key(settings: StoreSettings) -> str | None:
|
|
718
|
+
"""The effective base64 active key, sourced through the :class:`KeyProvider` seam selected by
|
|
719
|
+
``[store].key_provider`` (ADR 0019). The default ``auto`` provider is the env-then-DPAPI ladder —
|
|
720
|
+
``encryption_key`` (env/config) if set, else the Windows DPAPI-protected ``encryption_key_file``
|
|
721
|
+
decrypted (WP-11d), else ``None`` (→ identity cipher) — so the default is **byte-identical** to the
|
|
722
|
+
pre-seam behavior. The env key takes precedence so a deployment can override the file.
|
|
723
|
+
|
|
724
|
+
Fail-closed: a configured-but-unreadable/foreign DPAPI key file raises ``DpapiError`` here, and a
|
|
725
|
+
selected-but-unresolvable/unknown provider raises ``KeyProviderError`` — both propagate so
|
|
726
|
+
``serve`` refuses to start rather than silently degrading to the identity (plaintext) cipher."""
|
|
727
|
+
return resolve_key_provider(settings).active_key()
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
async def open_store(settings: StoreSettings) -> Store:
|
|
731
|
+
"""Open the store for the configured backend — the single backend-selection seam.
|
|
732
|
+
|
|
733
|
+
``sqlite`` is the default; ``postgres`` is a production server-DB backend with single-node parity
|
|
734
|
+
(lazy-imported, needs the ``postgres`` extra); ``sqlserver`` is a production server-DB backend,
|
|
735
|
+
lazy-imported (needs the ``sqlserver`` extra). Unknown backends raise ``NotImplementedError``.
|
|
736
|
+
"""
|
|
737
|
+
# AES-256-GCM keyring at rest when a key is set (STORE-1): active key (env or DPAPI key file) +
|
|
738
|
+
# any retired decrypt-only keys for an in-progress rotation (WP-5). No key → identity cipher.
|
|
739
|
+
retired = [k.strip() for k in settings.encryption_keys_retired.split(",") if k.strip()]
|
|
740
|
+
cipher = make_cipher(resolve_active_key(settings), retired)
|
|
741
|
+
if settings.backend is StoreBackend.SQLITE:
|
|
742
|
+
return await MessageStore.open(
|
|
743
|
+
settings.path, synchronous=settings.synchronous.value, cipher=cipher
|
|
744
|
+
)
|
|
745
|
+
if settings.backend is StoreBackend.SQLSERVER:
|
|
746
|
+
from messagefoundry.store.sqlserver import SqlServerStore # lazy: optional aioodbc dep
|
|
747
|
+
|
|
748
|
+
return await SqlServerStore.open(settings, cipher=cipher)
|
|
749
|
+
if settings.backend is StoreBackend.POSTGRES:
|
|
750
|
+
from messagefoundry.store.postgres import PostgresStore # lazy: optional asyncpg dep
|
|
751
|
+
|
|
752
|
+
return await PostgresStore.open(settings, cipher=cipher)
|
|
753
|
+
raise NotImplementedError(f"store backend {settings.backend.value!r} is not implemented yet")
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def sqlite_settings(path: str | Path, *, synchronous: str = "NORMAL") -> StoreSettings:
|
|
757
|
+
"""Build a SQLite ``StoreSettings`` (convenience for callers that only have a path)."""
|
|
758
|
+
return StoreSettings(path=str(path), synchronous=SqliteSync(synchronous.lower()))
|