lorebase-store 0.2.3__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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,558 @@
1
+ """The vault carried by a Domain bundle (design 13.5, `lorebase_store.bundle`).
2
+
3
+ A bundle exported with its vault values holds three sections:
4
+
5
+ - **entries**, one per token its Record objects reference: the Kind, Attribute and Record key
6
+ the vault row binds the token to, and either the opened value or the mark that it was erased.
7
+ A token without a row (imported earlier without its value) is exported as erased, bound to a
8
+ Record holding it. The values are plain text; only an explicit export writes them.
9
+ - **lineage**: the key changes of `vault_lineage`. A key change on an erased reference keeps
10
+ the token, so the Records before and after it both hold it; `KeyLinks` links their keys.
11
+ An entry carries the row's `moved_to` (the key a key change took the token over to), which
12
+ import accepts only along a key change of the bundle.
13
+ - **tombstones**: the fingerprints erasures recorded (`vault_tombstones`), with the token key
14
+ they were made under (`fingerprint_key`, `<token_key_id>/<scheme>`). They hold no value and
15
+ cannot be made again, so a target only takes them when it has that token key id. Legacy
16
+ tombstones without a `fingerprint_key` are exported as made under the current token key with
17
+ the first scheme (`resolved_legacy`); import refuses one that still has none.
18
+
19
+ `holders_problem` checks that the Records holding a token in a bundle hold it in the bound Kind
20
+ and Attribute, under the bound key or one linked to it. `restore_entries` seals values again
21
+ under the target's keys with the bundle's token as associated data and the target's fingerprint,
22
+ so they open, and erasure by value finds them, as if written there; a value whose fingerprint a
23
+ tombstone of the target holds is handled as `vault.erased_values` says (stored erased, refused,
24
+ or stored). Erased entries become erased rows. Tokens the target already holds are refused.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from collections import Counter, defaultdict
31
+ from collections.abc import AsyncIterator, Collection, Mapping, Sequence
32
+ from dataclasses import dataclass, replace
33
+ from datetime import datetime
34
+ from typing import Any
35
+
36
+ from pydantic import JsonValue
37
+ from sqlalchemy import Table, select, update
38
+ from sqlalchemy.dialects.postgresql import insert
39
+ from sqlalchemy.ext.asyncio import AsyncSession
40
+
41
+ from lorebase_core.canonical import from_hex
42
+ from lorebase_core.canonical import hex as to_hex
43
+ from lorebase_core.model import Attribute, looks_like_reference
44
+ from lorebase_core.model.vault import TOKEN_PATTERN
45
+ from lorebase_core.validate import check_values
46
+ from lorebase_store.models import metadata
47
+ from lorebase_store.vault.keys import VaultKeys
48
+ from lorebase_store.vault.settings import ErasedValues
49
+ from lorebase_store.vault.store import (
50
+ Lineage,
51
+ VaultRow,
52
+ insert_lineage,
53
+ insert_rows,
54
+ )
55
+
56
+ _VAULT: Table = metadata.tables["vault"]
57
+ _TOMBSTONES: Table = metadata.tables["vault_tombstones"]
58
+ _LINEAGE: Table = metadata.tables["vault_lineage"]
59
+ _CHUNK = 1000
60
+ _FINGERPRINT_BYTES = 32
61
+ _ENTRY_FIELDS = frozenset(
62
+ {"token", "kind", "attribute", "record_key", "value", "erased", "moved_to"}
63
+ )
64
+ LEGACY_SCHEME = 1
65
+ """Fingerprint scheme of tombstones recorded before rows kept their `fingerprint_key`."""
66
+ _LINEAGE_FIELDS = frozenset({"kind", "record_key", "previous_key"})
67
+ _TOMBSTONE_FIELDS = frozenset({"kind", "attribute", "record_key", "fingerprint", "fingerprint_key"})
68
+
69
+
70
+ class VaultTransferError(ValueError):
71
+ """A vault section cannot be exported, is malformed, or cannot be stored."""
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class VaultEntry:
76
+ """A token, its binding and its value (`erased`: no value). `moved_to`: the key a key
77
+ change took the token over to."""
78
+
79
+ token: str
80
+ kind_id: str
81
+ attribute_id: str
82
+ record_key: str
83
+ value: JsonValue = None
84
+ erased: bool = False
85
+ moved_to: str | None = None
86
+
87
+ def as_json(self) -> dict[str, Any]:
88
+ out: dict[str, Any] = {
89
+ "token": self.token,
90
+ "kind": self.kind_id,
91
+ "attribute": self.attribute_id,
92
+ "record_key": self.record_key,
93
+ }
94
+ if self.erased:
95
+ out["erased"] = True
96
+ else:
97
+ out["value"] = self.value
98
+ if self.moved_to is not None:
99
+ out["moved_to"] = self.moved_to
100
+ return out
101
+
102
+
103
+ @dataclass(frozen=True, slots=True, order=True)
104
+ class Holder:
105
+ """A Record holding a token in an Attribute, as a bundle's objects show it."""
106
+
107
+ kind_id: str
108
+ attribute_id: str
109
+ record_key: str
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class LineageEntry:
114
+ """Record `record_key` took over the vaulted values of `previous_key` (same Kind)."""
115
+
116
+ kind_id: str
117
+ record_key: str
118
+ previous_key: str
119
+
120
+ def as_json(self) -> dict[str, Any]:
121
+ return {
122
+ "kind": self.kind_id,
123
+ "record_key": self.record_key,
124
+ "previous_key": self.previous_key,
125
+ }
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class TombstoneEntry:
130
+ """A fingerprint an erasure recorded, of one Record (`record_key`) or of the Kind."""
131
+
132
+ kind_id: str
133
+ attribute_id: str
134
+ record_key: str | None
135
+ fingerprint: bytes
136
+ fingerprint_key: str | None
137
+
138
+ def as_json(self) -> dict[str, Any]:
139
+ return {
140
+ "kind": self.kind_id,
141
+ "attribute": self.attribute_id,
142
+ "record_key": self.record_key,
143
+ "fingerprint": to_hex(self.fingerprint),
144
+ "fingerprint_key": self.fingerprint_key,
145
+ }
146
+
147
+
148
+ class KeyLinks:
149
+ """Record keys of a Kind linked by key changes, transitively in both directions."""
150
+
151
+ def __init__(self) -> None:
152
+ self._parent: dict[tuple[str, str], tuple[str, str]] = {}
153
+
154
+ def _root(self, node: tuple[str, str]) -> tuple[str, str]:
155
+ while (parent := self._parent.get(node, node)) != node:
156
+ grand = self._parent.get(parent, parent)
157
+ self._parent[node] = grand
158
+ node = grand
159
+ return node
160
+
161
+ def link(self, entry: LineageEntry) -> None:
162
+ for key in (entry.record_key, entry.previous_key):
163
+ self._parent.setdefault((entry.kind_id, key), (entry.kind_id, key))
164
+ a = self._root((entry.kind_id, entry.record_key))
165
+ b = self._root((entry.kind_id, entry.previous_key))
166
+ if a != b:
167
+ self._parent[a] = b
168
+
169
+ def linked(self, kind_id: str, key: str, other: str) -> bool:
170
+ return key == other or self._root((kind_id, key)) == self._root((kind_id, other))
171
+
172
+ def oversized(self, limit: int) -> int:
173
+ """Groups of linked keys with more than `limit` keys."""
174
+ sizes = Counter(self._root(node) for node in list(self._parent))
175
+ return sum(1 for size in sizes.values() if size > limit)
176
+
177
+
178
+ def holders_problem(
179
+ token: str,
180
+ kind_id: str,
181
+ attribute_id: str,
182
+ record_key: str,
183
+ holders: Collection[Holder],
184
+ links: KeyLinks,
185
+ ) -> str | None:
186
+ """Why the Records holding `token` do not fit its binding, or None: each must hold it in
187
+ the bound Kind and Attribute, under the bound key or a key linked to it."""
188
+ if not holders:
189
+ return f"no Record object of the bundle holds {token}"
190
+ for holder in sorted(holders):
191
+ if (holder.kind_id, holder.attribute_id) != (kind_id, attribute_id) or not links.linked(
192
+ kind_id, holder.record_key, record_key
193
+ ):
194
+ return (
195
+ f"{token} is held by Record {holder.record_key!r} in Attribute "
196
+ f"{holder.attribute_id}, which is neither its binding nor linked to it"
197
+ )
198
+ return None
199
+
200
+
201
+ async def read_entries(
202
+ session: AsyncSession, domain_id: str, tokens: Sequence[str], keys: VaultKeys
203
+ ) -> dict[str, VaultEntry]:
204
+ """The entries of the tokens that have a vault row, bound as the row is. A value sealed
205
+ under a key that is not configured cannot be exported."""
206
+ v = _VAULT
207
+ out: dict[str, VaultEntry] = {}
208
+ ordered = sorted(set(tokens))
209
+ for start in range(0, len(ordered), _CHUNK):
210
+ rows = await session.execute(
211
+ select(
212
+ v.c.id,
213
+ v.c.kind_id,
214
+ v.c.attribute_id,
215
+ v.c.record_key,
216
+ v.c.value_ciphertext,
217
+ v.c.key_id,
218
+ v.c.moved_to,
219
+ ).where(v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK]))
220
+ )
221
+ for row in rows:
222
+ if row.value_ciphertext is None or row.key_id is None:
223
+ out[row.id] = VaultEntry(
224
+ row.id,
225
+ row.kind_id,
226
+ row.attribute_id,
227
+ row.record_key,
228
+ erased=True,
229
+ moved_to=row.moved_to,
230
+ )
231
+ continue
232
+ opened = keys.open(domain_id, row.id, row.key_id, bytes(row.value_ciphertext))
233
+ if opened is None:
234
+ raise VaultTransferError(
235
+ f"the value of {row.id} is sealed under a key that is not configured "
236
+ "(vault.previous_keys)"
237
+ )
238
+ out[row.id] = VaultEntry(
239
+ row.id,
240
+ row.kind_id,
241
+ row.attribute_id,
242
+ row.record_key,
243
+ json.loads(opened),
244
+ moved_to=row.moved_to,
245
+ )
246
+ return out
247
+
248
+
249
+ def resolved_legacy(tombstone: TombstoneEntry, token_key_id: str) -> TombstoneEntry:
250
+ """A tombstone recorded before tombstones kept their `fingerprint_key` holds a fingerprint
251
+ of the first scheme under the token key of the installation: it is exported as made under
252
+ the current one (`<token_key_id>/1`), which the manifest says."""
253
+ if tombstone.fingerprint_key is not None:
254
+ return tombstone
255
+ return replace(tombstone, fingerprint_key=f"{token_key_id}/{LEGACY_SCHEME}")
256
+
257
+
258
+ def unknown_entry(token: str, holders: Collection[Holder]) -> VaultEntry:
259
+ """The entry of a token without a vault row: erased, bound to its first holder."""
260
+ first = min(holders)
261
+ return VaultEntry(token, first.kind_id, first.attribute_id, first.record_key, erased=True)
262
+
263
+
264
+ async def lineage_of(session: AsyncSession, domain_id: str) -> list[LineageEntry]:
265
+ """The Domain's key changes."""
266
+ lin = _LINEAGE
267
+ rows = await session.execute(
268
+ select(lin.c.kind_id, lin.c.record_key, lin.c.previous_key)
269
+ .where(lin.c.domain_id == domain_id)
270
+ .order_by(lin.c.kind_id, lin.c.record_key, lin.c.previous_key)
271
+ )
272
+ return [LineageEntry(r.kind_id, r.record_key, r.previous_key) for r in rows]
273
+
274
+
275
+ async def tombstones_of(
276
+ session: AsyncSession, domain_id: str, batch: int
277
+ ) -> AsyncIterator[list[TombstoneEntry]]:
278
+ """The Domain's tombstones, `batch` at a time."""
279
+ t = _TOMBSTONES
280
+ after = 0
281
+ while True:
282
+ rows = (
283
+ await session.execute(
284
+ select(
285
+ t.c.seq,
286
+ t.c.kind_id,
287
+ t.c.attribute_id,
288
+ t.c.record_key,
289
+ t.c.fingerprint,
290
+ t.c.fingerprint_key,
291
+ )
292
+ .where(t.c.domain_id == domain_id, t.c.seq > after)
293
+ .order_by(t.c.seq)
294
+ .limit(batch)
295
+ )
296
+ ).all()
297
+ if not rows:
298
+ return
299
+ yield [
300
+ TombstoneEntry(
301
+ r.kind_id, r.attribute_id, r.record_key, bytes(r.fingerprint), r.fingerprint_key
302
+ )
303
+ for r in rows
304
+ ]
305
+ after = int(rows[-1].seq)
306
+
307
+
308
+ def _strings(line: Mapping[str, Any], names: Sequence[str], label: str) -> list[str]:
309
+ found = [line.get(name) for name in names]
310
+ if not all(isinstance(part, str) and part for part in found):
311
+ raise VaultTransferError(f"{label}: {', '.join(names)} must be non-empty strings")
312
+ return [str(part) for part in found]
313
+
314
+
315
+ def _fields(line: Mapping[str, Any], allowed: frozenset[str]) -> None:
316
+ unknown = set(line) - allowed
317
+ if unknown:
318
+ raise VaultTransferError(f"unexpected fields {sorted(unknown)}")
319
+
320
+
321
+ def parse_entry(line: Mapping[str, Any]) -> VaultEntry:
322
+ """A checked entry: a well-formed token, its binding as strings, and exactly one of a
323
+ value (never a reference) or the erased mark."""
324
+ _fields(line, _ENTRY_FIELDS)
325
+ token = line.get("token")
326
+ if not isinstance(token, str) or not TOKEN_PATTERN.fullmatch(token):
327
+ raise VaultTransferError(f"malformed token {token!r}")
328
+ kind_id, attribute_id, record_key = _strings(line, ("kind", "attribute", "record_key"), token)
329
+ erased = line.get("erased", False)
330
+ if erased is not True and erased is not False:
331
+ raise VaultTransferError(f"{token}: erased must be true when present")
332
+ has_value = "value" in line
333
+ if erased == has_value:
334
+ raise VaultTransferError(f"{token}: give either a value or erased")
335
+ value = line.get("value")
336
+ if has_value and (value is None or looks_like_reference(value)):
337
+ raise VaultTransferError(f"{token}: the value is empty or a vault reference")
338
+ moved_to = line.get("moved_to")
339
+ if moved_to is not None and (
340
+ not isinstance(moved_to, str) or not moved_to or moved_to == record_key
341
+ ):
342
+ raise VaultTransferError(f"{token}: moved_to must be another Record key")
343
+ return VaultEntry(token, kind_id, attribute_id, record_key, value, erased, moved_to)
344
+
345
+
346
+ def parse_lineage(line: Mapping[str, Any]) -> LineageEntry:
347
+ """A checked key change."""
348
+ _fields(line, _LINEAGE_FIELDS)
349
+ kind_id, record_key, previous_key = _strings(
350
+ line, ("kind", "record_key", "previous_key"), "key change"
351
+ )
352
+ if record_key == previous_key:
353
+ raise VaultTransferError(f"key change of {record_key!r} to itself")
354
+ return LineageEntry(kind_id, record_key, previous_key)
355
+
356
+
357
+ def parse_tombstone(line: Mapping[str, Any], token_key_ids: Collection[str]) -> TombstoneEntry:
358
+ """A checked tombstone, made under one of `token_key_ids` (the target's token keys)."""
359
+ _fields(line, _TOMBSTONE_FIELDS)
360
+ kind_id, attribute_id = _strings(line, ("kind", "attribute"), "tombstone")
361
+ record_key = line.get("record_key")
362
+ if record_key is not None and (not isinstance(record_key, str) or not record_key):
363
+ raise VaultTransferError("tombstone: record_key must be a non-empty string or null")
364
+ raw = line.get("fingerprint")
365
+ try:
366
+ fingerprint = from_hex(raw) if isinstance(raw, str) else b""
367
+ except ValueError:
368
+ fingerprint = b""
369
+ if len(fingerprint) != _FINGERPRINT_BYTES:
370
+ raise VaultTransferError(f"tombstone: malformed fingerprint {raw!r}")
371
+ fingerprint_key = line.get("fingerprint_key")
372
+ if fingerprint_key is None:
373
+ raise VaultTransferError(
374
+ "a tombstone does not say which token key made it (a bundle of an earlier "
375
+ "release); export the Domain again, which records it"
376
+ )
377
+ key_id = fingerprint_key.partition("/")[0] if isinstance(fingerprint_key, str) else None
378
+ if key_id is None or "/" not in str(fingerprint_key) or key_id not in token_key_ids:
379
+ raise VaultTransferError(
380
+ f"a tombstone was made under token key {fingerprint_key!r}, which this "
381
+ "installation does not have (vault.token_key_id, vault.previous_token_keys); "
382
+ "configure that token key under the same id to import the bundle"
383
+ )
384
+ return TombstoneEntry(kind_id, attribute_id, record_key, fingerprint, fingerprint_key)
385
+
386
+
387
+ def value_problem(entry: VaultEntry, definitions: Sequence[Attribute]) -> str | None:
388
+ """Why the value of `entry` fits none of the definitions its Attribute had in the bundle's
389
+ schemas (type, canonical form, constraints), or None."""
390
+ if not definitions:
391
+ return f"{entry.token}: the bundle's schemas have no vault Attribute {entry.attribute_id}"
392
+ issues: list[str] = []
393
+ for attribute in definitions:
394
+ found = check_values(
395
+ {entry.attribute_id: attribute},
396
+ {entry.attribute_id: entry.value},
397
+ ("vault",),
398
+ kind=entry.kind_id,
399
+ key=entry.record_key,
400
+ )
401
+ if not found:
402
+ return None
403
+ issues.append(found[0].message)
404
+ return f"{entry.token}: {issues[0]}"
405
+
406
+
407
+ async def existing_tokens(
408
+ session: AsyncSession, domain_id: str, tokens: Collection[str]
409
+ ) -> set[str]:
410
+ """The tokens among `tokens` that the Domain's vault already holds."""
411
+ v = _VAULT
412
+ ordered = sorted(tokens)
413
+ found: set[str] = set()
414
+ for start in range(0, len(ordered), _CHUNK):
415
+ rows = await session.execute(
416
+ select(v.c.id).where(
417
+ v.c.domain_id == domain_id, v.c.id.in_(ordered[start : start + _CHUNK])
418
+ )
419
+ )
420
+ found.update(rows.scalars())
421
+ return found
422
+
423
+
424
+ async def restore_lineage(
425
+ session: AsyncSession, domain_id: str, entries: Sequence[LineageEntry], *, at: datetime
426
+ ) -> None:
427
+ """Store key changes."""
428
+ for start in range(0, len(entries), _CHUNK):
429
+ chunk = entries[start : start + _CHUNK]
430
+ await insert_lineage(
431
+ session,
432
+ domain_id,
433
+ [Lineage(e.kind_id, e.record_key, e.previous_key) for e in chunk],
434
+ at=at,
435
+ )
436
+
437
+
438
+ async def restore_tombstones(
439
+ session: AsyncSession, domain_id: str, entries: Sequence[TombstoneEntry], *, at: datetime
440
+ ) -> None:
441
+ """Store tombstones as they were made."""
442
+ if not entries:
443
+ return
444
+ await session.execute(
445
+ insert(_TOMBSTONES).values(
446
+ [
447
+ {
448
+ "domain_id": domain_id,
449
+ "kind_id": e.kind_id,
450
+ "attribute_id": e.attribute_id,
451
+ "record_key": e.record_key,
452
+ "fingerprint": e.fingerprint,
453
+ "fingerprint_key": e.fingerprint_key,
454
+ "created_at": at,
455
+ }
456
+ for e in entries
457
+ ]
458
+ )
459
+ )
460
+
461
+
462
+ async def _tombstoned(
463
+ session: AsyncSession, domain_id: str, values: Sequence[VaultEntry], keys: VaultKeys
464
+ ) -> set[str]:
465
+ """Tokens of the values a tombstone of the Domain holds, for their Record or the Kind."""
466
+ fingerprints = {
467
+ e.token: keys.fingerprints(domain_id, e.kind_id, e.attribute_id, e.value) for e in values
468
+ }
469
+ wanted = sorted({f for found in fingerprints.values() for f in found})
470
+ t = _TOMBSTONES
471
+ erased: dict[bytes, list[tuple[str, str, str | None]]] = defaultdict(list)
472
+ for start in range(0, len(wanted), _CHUNK):
473
+ rows = await session.execute(
474
+ select(t.c.fingerprint, t.c.kind_id, t.c.attribute_id, t.c.record_key).where(
475
+ t.c.domain_id == domain_id, t.c.fingerprint.in_(wanted[start : start + _CHUNK])
476
+ )
477
+ )
478
+ for row in rows:
479
+ erased[bytes(row.fingerprint)].append((row.kind_id, row.attribute_id, row.record_key))
480
+ if not erased:
481
+ return set()
482
+ return {
483
+ e.token
484
+ for e in values
485
+ if any(
486
+ (kind_id, attribute_id) == (e.kind_id, e.attribute_id)
487
+ and record_key in (None, e.record_key)
488
+ for fingerprint in fingerprints[e.token]
489
+ for kind_id, attribute_id, record_key in erased.get(fingerprint, ())
490
+ )
491
+ }
492
+
493
+
494
+ async def restore_entries(
495
+ session: AsyncSession,
496
+ domain_id: str,
497
+ entries: Sequence[VaultEntry],
498
+ keys: VaultKeys | None,
499
+ *,
500
+ erased_values: ErasedValues,
501
+ at: datetime,
502
+ ) -> int:
503
+ """Store `entries` in the Domain's vault (module doc); the number of values stored erased
504
+ because a tombstone holds them."""
505
+ if not entries:
506
+ return 0
507
+ values = [e for e in entries if not e.erased]
508
+ if values and keys is None:
509
+ raise VaultTransferError(
510
+ "the bundle holds vault values but the vault is not configured "
511
+ "(vault.token_key, vault.encryption_key)"
512
+ )
513
+ taken = await existing_tokens(session, domain_id, [e.token for e in entries])
514
+ if taken:
515
+ raise VaultTransferError(
516
+ f"the vault already holds {len(taken)} of the bundle's tokens, e.g. {min(taken)}"
517
+ )
518
+ tombstoned: set[str] = set()
519
+ if values and erased_values is not ErasedValues.ALLOW:
520
+ assert keys is not None
521
+ tombstoned = await _tombstoned(session, domain_id, values, keys)
522
+ if tombstoned and erased_values is ErasedValues.REFUSE:
523
+ raise VaultTransferError(
524
+ f"{len(tombstoned)} values of the bundle were erased, e.g. {min(tombstoned)} "
525
+ "(vault.erased_values: refuse)"
526
+ )
527
+ rows: list[VaultRow] = []
528
+ for e in entries:
529
+ if e.erased or e.token in tombstoned:
530
+ rows.append(
531
+ VaultRow(
532
+ e.token, e.kind_id, e.attribute_id, e.record_key, None, None, None, erased=True
533
+ )
534
+ )
535
+ continue
536
+ assert keys is not None
537
+ rows.append(
538
+ VaultRow(
539
+ token=e.token,
540
+ kind_id=e.kind_id,
541
+ attribute_id=e.attribute_id,
542
+ record_key=e.record_key,
543
+ fingerprint=keys.fingerprint(domain_id, e.kind_id, e.attribute_id, e.value),
544
+ ciphertext=keys.seal(domain_id, e.token, e.value),
545
+ key_id=keys.key_id,
546
+ fingerprint_key=keys.fingerprint_key,
547
+ )
548
+ )
549
+ await insert_rows(session, domain_id, rows, at=at)
550
+ for e in entries:
551
+ if e.moved_to is not None:
552
+ # Restored as exported: no chain to follow, every row carries its own moved_to.
553
+ await session.execute(
554
+ update(_VAULT)
555
+ .where(_VAULT.c.domain_id == domain_id, _VAULT.c.id == e.token)
556
+ .values(moved_to=e.moved_to)
557
+ )
558
+ return len(tombstoned)
@@ -0,0 +1,24 @@
1
+ """Webhooks: secrets and signatures, management of a Domain's webhooks, and delivery from the
2
+ outbox (design 10.5)."""
3
+
4
+ from lorebase_store.webhooks.delivery import DeliveryReport, Sender, deliver_pending
5
+ from lorebase_store.webhooks.keys import SigningKeys, StoredKey
6
+ from lorebase_store.webhooks.repository import WebhookRepository, WebhookRow
7
+ from lorebase_store.webhooks.settings import WebhookSettings, webhook_settings
8
+ from lorebase_store.webhooks.signing import new_secret, signature, signing_key, verify
9
+
10
+ __all__ = [
11
+ "DeliveryReport",
12
+ "Sender",
13
+ "SigningKeys",
14
+ "StoredKey",
15
+ "WebhookRepository",
16
+ "WebhookRow",
17
+ "WebhookSettings",
18
+ "deliver_pending",
19
+ "new_secret",
20
+ "signature",
21
+ "signing_key",
22
+ "verify",
23
+ "webhook_settings",
24
+ ]