msgctl 0.1.0a1__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 (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/storage/query.py ADDED
@@ -0,0 +1,39 @@
1
+ """Session-bound query results without a public driver cursor/connection."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Iterator
5
+ from typing import Protocol
6
+ from msg.core.query import SqlRow
7
+
8
+
9
+ class _Cursor(Protocol):
10
+ @property
11
+ def rowcount(self) -> int: ...
12
+ def fetchone(self) -> SqlRow | None: ...
13
+ def fetchall(self) -> list[SqlRow]: ...
14
+
15
+
16
+ class SessionQueryResult:
17
+ """A narrow view, not a sandbox for trusted in-process Python consumers."""
18
+ __slots__ = ('_cursor', '_check')
19
+
20
+ def __init__(self, cursor: _Cursor, check: Callable[[], None]):
21
+ self._cursor = cursor
22
+ self._check = check
23
+
24
+ @property
25
+ def rowcount(self) -> int:
26
+ self._check()
27
+ return self._cursor.rowcount
28
+
29
+ def fetchone(self) -> SqlRow | None:
30
+ self._check()
31
+ return self._cursor.fetchone()
32
+
33
+ def fetchall(self) -> list[SqlRow]:
34
+ self._check()
35
+ return self._cursor.fetchall()
36
+
37
+ def __iter__(self) -> Iterator[SqlRow]:
38
+ while (row := self.fetchone()) is not None:
39
+ yield row
@@ -0,0 +1,126 @@
1
+ """Storage capabilities for a process which must never initialize or publish.
2
+
3
+ PostgreSQL grants are an independent boundary, not replaced by READ ONLY or a
4
+ Python wrapper. Content reads share the exact existing Git/CAS implementation,
5
+ without constructing a writer or exposing its publication methods.
6
+ """
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ import os
10
+
11
+ from msg.core.errors import require
12
+ from msg.storage.git import GitContentStore
13
+ from msg.storage.postgres import PostgresMetadataStore
14
+
15
+
16
+ HOSTING_READ_TABLES = frozenset({
17
+ 'schema_version', 'resources', 'revisions', 'identities', 'memberships',
18
+ 'credentials', 'certificates', 'settings', 'share_grants', 'share_grants_v2',
19
+ 'dm_conversations', 'system_sources',
20
+ })
21
+
22
+
23
+ def require_readonly_role(tx):
24
+ """Refuse owner/writer/SET ROLE/secret-table authority; never repair grants."""
25
+ role = tx.one('''SELECT current_user, session_user, rolsuper, rolcreaterole,
26
+ rolcreatedb, rolreplication, rolbypassrls FROM pg_catalog.pg_roles
27
+ WHERE rolname=current_user''')
28
+ require(role is not None and role[0] == role[1] and not any(role[2:]),
29
+ 'hosting_database_not_readonly')
30
+ # An owner can GRANT its own rights back after REVOKE ALL. Current ACL bits
31
+ # cannot establish read-only authority even when every write check is false.
32
+ require(tx.one('''SELECT 1 FROM (
33
+ SELECT datdba AS owner_id FROM pg_catalog.pg_database WHERE datname=current_database()
34
+ UNION ALL SELECT nspowner FROM pg_catalog.pg_namespace
35
+ UNION ALL SELECT relowner FROM pg_catalog.pg_class
36
+ UNION ALL SELECT proowner FROM pg_catalog.pg_proc
37
+ ) AS owned WHERE owner_id=(SELECT oid FROM pg_catalog.pg_roles
38
+ WHERE rolname=current_user) LIMIT 1''') is None, 'hosting_database_not_readonly')
39
+ require(tx.one('''SELECT 1 FROM pg_catalog.pg_roles
40
+ WHERE rolname<>current_user AND pg_has_role(oid,'MEMBER') LIMIT 1''') is None,
41
+ 'hosting_database_not_readonly')
42
+ require(not tx.one("SELECT has_database_privilege(current_database(),'CREATE')")[0],
43
+ 'hosting_database_not_readonly')
44
+ require(tx.one('''SELECT 1 FROM pg_catalog.pg_namespace
45
+ WHERE left(nspname,3)<>'pg_' AND nspname<>'information_schema'
46
+ AND has_schema_privilege(oid,'CREATE') LIMIT 1''') is None,
47
+ 'hosting_database_not_readonly')
48
+ available = set()
49
+ for schema, name, readable, writable, full_read in tx.rows('''
50
+ SELECT n.nspname,c.relname,has_any_column_privilege(c.oid,'SELECT'),
51
+ (has_table_privilege(c.oid,'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER')
52
+ OR has_any_column_privilege(c.oid,'INSERT,UPDATE,REFERENCES')),
53
+ has_table_privilege(c.oid,'SELECT')
54
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
55
+ WHERE c.relkind IN ('r','p','v','m','f')
56
+ AND left(n.nspname,3)<>'pg_' AND n.nspname<>'information_schema'
57
+ '''):
58
+ require(not writable and (not readable or
59
+ (schema == 'public' and name in HOSTING_READ_TABLES)),
60
+ 'hosting_database_not_readonly')
61
+ if schema == 'public' and full_read:
62
+ available.add(name)
63
+ require(available == HOSTING_READ_TABLES, 'hosting_installation_stale')
64
+ # The installed PostgreSQL metadata format is version 1. A reader cannot
65
+ # initialize or migrate it, or infer readiness from a few working tables.
66
+ require(tx.rows('SELECT version FROM public.schema_version') == [(1,)],
67
+ 'hosting_installation_stale')
68
+ # Fetch sequence OIDs first: SQL predicates do not impose evaluation order,
69
+ # and has_sequence_privilege must never receive an index/table OID.
70
+ for (oid,) in tx.rows('''SELECT c.oid FROM pg_catalog.pg_class c
71
+ JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
72
+ WHERE c.relkind='S' AND left(n.nspname,3)<>'pg_' '''):
73
+ require(not tx.one("SELECT has_sequence_privilege(?::oid,'USAGE,UPDATE')", (oid,))[0],
74
+ 'hosting_database_not_readonly')
75
+ # A callable SECURITY DEFINER routine can reintroduce its owner's privileges.
76
+ require(tx.one('''SELECT 1 FROM pg_catalog.pg_proc p
77
+ JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace
78
+ WHERE p.prosecdef AND left(n.nspname,3)<>'pg_'
79
+ AND n.nspname<>'information_schema'
80
+ AND has_function_privilege(p.oid,'EXECUTE') LIMIT 1''') is None,
81
+ 'hosting_database_not_readonly')
82
+
83
+
84
+ class ReadOnlyPostgresStore:
85
+ """Reuse canonical sessions/transactions, without migration or a write API."""
86
+ def __init__(self, dsn):
87
+ self._store = PostgresMetadataStore(dsn, initialize=False)
88
+
89
+ @asynccontextmanager
90
+ async def transaction(self, *, write=False):
91
+ require(not write, 'read_only_transaction')
92
+ async with self._store.transaction(write=False) as tx:
93
+ # Explicit ordering prevents writable/temp schemas from shadowing
94
+ # the installed public schema or PostgreSQL's privilege functions.
95
+ tx.execute('SET LOCAL search_path = pg_catalog, public, pg_temp')
96
+ require_readonly_role(tx)
97
+ yield tx
98
+
99
+ async def close(self):
100
+ await self._store.close()
101
+
102
+
103
+ class GitContentReader:
104
+ """Read existing content; no mkdir, init, staging, pin, write or writer pointer."""
105
+ def __init__(self, path, *, binary_dir=None):
106
+ self.path = Path(path)
107
+ self.repo = self.path / 'private.git'
108
+ self.index = self.path / 'index'
109
+ self.binary = Path(binary_dir) if binary_dir is not None else self.path / 'binary'
110
+ require(all(p.is_dir() for p in (self.path, self.repo, self.index, self.binary)),
111
+ 'content_store_not_initialized')
112
+ self.env = {
113
+ 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), 'LANG': 'C.UTF-8',
114
+ 'HOME': str(self.path), 'GIT_CONFIG_NOSYSTEM': '1',
115
+ 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_OPTIONAL_LOCKS': '0',
116
+ # The approved content repository is owned by the writer, not this UID.
117
+ 'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'safe.directory',
118
+ 'GIT_CONFIG_VALUE_0': str(self.repo),
119
+ }
120
+
121
+ # Unbound implementations, not a wrapped GitContentStore instance. Keep the
122
+ # original limits, references, streaming, range and truncation behavior.
123
+ _key = staticmethod(GitContentStore._key)
124
+ _entry = GitContentStore._entry
125
+ read = GitContentStore.read
126
+ read_bytes = GitContentStore.read_bytes
msg/storage/session.py ADDED
@@ -0,0 +1,403 @@
1
+ """Shared relational domain methods, with no driver or transaction ownership.
2
+
3
+ This SQL-oriented monolith shares the supported placeholder/query subset, not
4
+ an imaginary database-independent object store. Adapters own SQL translation,
5
+ schema migration, connections, writer fences, savepoints and commit/rollback.
6
+ Only this session's owning task can access its transaction or results.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import replace
13
+ from msg.core.codec import canonical, decode, digest, loads, wire
14
+ from msg.core.errors import require
15
+ from msg.core.query import QueryResult, SettingValue, SqlParameters, SqlRow
16
+ from msg.core.models import (
17
+ AuditEvent, Certificate, CertificateRequest, CertificateRequestState, Credential,
18
+ EffectJob, EmailSettings, Event, Membership, OperationResult, Organization, Page,
19
+ Resource, ResourceRef, Revision, Subject, TransferChunk, TransferSession,
20
+ )
21
+
22
+
23
+ class RelationalSession(ABC):
24
+ def __init__(self, *, write: bool):
25
+ self.write = write
26
+ self.owner_task = asyncio.current_task()
27
+ self.closed = False
28
+ self.rollback_effects = []
29
+
30
+ def on_rollback(self, effect):
31
+ """Compensate external work if this transaction or savepoint aborts."""
32
+ self.check(write=True)
33
+ self.rollback_effects.append(effect)
34
+
35
+ async def run_rollback_effects(self, cause, start=0):
36
+ effects = self.rollback_effects[start:]
37
+ del self.rollback_effects[start:]
38
+ if not effects:
39
+ return
40
+
41
+ async def compensate():
42
+ for effect in reversed(effects):
43
+ try:
44
+ await effect()
45
+ except BaseException as exc:
46
+ # Keep the primary error and try every independent cleanup.
47
+ cause.add_note('rollback compensation failed: ' + type(exc).__name__)
48
+
49
+ # A second cancellation must not abandon a pin operation (including its
50
+ # worker thread) and release the writer fence before it finishes.
51
+ pending = asyncio.create_task(compensate())
52
+ while not pending.done():
53
+ try:
54
+ await asyncio.shield(pending)
55
+ except asyncio.CancelledError:
56
+ pass
57
+ pending.result()
58
+
59
+ def check(self, write=False):
60
+ require(not self.closed, "transaction_closed")
61
+ require(asyncio.current_task() is self.owner_task, "transaction_cross_task")
62
+ if write:
63
+ require(self.write, "read_only_transaction")
64
+
65
+ @abstractmethod
66
+ def execute(self, sql: str, parameters: SqlParameters = (), *,
67
+ write: bool = False) -> QueryResult:
68
+ """Run adapter-normalized SQL in this store-owned transaction."""
69
+ raise NotImplementedError
70
+
71
+ def one(self, sql: str, parameters: SqlParameters = ()) -> SqlRow | None:
72
+ return self.execute(sql, parameters).fetchone()
73
+
74
+ def rows(self, sql: str, parameters: SqlParameters = ()) -> list[SqlRow]:
75
+ return self.execute(sql, parameters).fetchall()
76
+
77
+ async def resource(self, id):
78
+ row = self.one("SELECT body FROM resources WHERE id=?", (id,))
79
+ require(row is not None, "not_found")
80
+ return decode(Resource, loads(row[0]))
81
+
82
+ async def resolve(self, path):
83
+ require(isinstance(path, str) and path.startswith("/"), "invalid_path")
84
+ parts = path.rstrip("/").split("/")[1:]
85
+ require(all(p not in {".", ".."} and "\\" not in p and "\x00" not in p for p in parts), "invalid_path")
86
+ if len(parts) == 2 and parts[0] == "_id":
87
+ return (await self.resource(parts[1])).id
88
+ row = self.one("SELECT id FROM resources WHERE parent IS NULL")
89
+ require(row is not None, "not_initialized")
90
+ rid = row[0]
91
+ if path == "/":
92
+ return rid
93
+ for part in parts:
94
+ require(bool(part), "invalid_path")
95
+ row = self.one("SELECT id FROM resources WHERE parent=? AND name=?", (rid, part))
96
+ require(row is not None, "not_found")
97
+ rid = row[0]
98
+ return rid
99
+
100
+ async def path(self, id):
101
+ segments, seen = [], set()
102
+ r = await self.resource(id)
103
+ while r.parent is not None:
104
+ require(r.id not in seen, "parent_cycle")
105
+ seen.add(r.id)
106
+ segments.append(r.name)
107
+ r = await self.resource(r.parent)
108
+ return "/" + "/".join(reversed(segments))
109
+
110
+ async def ancestors(self, id):
111
+ result, seen = [], set()
112
+ r = await self.resource(id)
113
+ while r.parent is not None:
114
+ require(r.id not in seen, "parent_cycle")
115
+ seen.add(r.id)
116
+ r = await self.resource(r.parent)
117
+ result.append(r)
118
+ return tuple(reversed(result))
119
+
120
+ async def children(self, parent, cursor=None, limit=50):
121
+ require(type(limit) is int and 1 <= limit <= 500, "invalid_limit")
122
+ rows = self.rows("SELECT body FROM resources WHERE parent=? AND id>? ORDER BY id LIMIT ?",
123
+ (parent, cursor or "", limit + 1))
124
+ values = tuple(decode(Resource, loads(row[0])) for row in rows[:limit])
125
+ return Page(items=values, next_cursor=values[-1].id if len(rows) > limit else None)
126
+
127
+ async def revision(self, ref):
128
+ rid = ref.revision or (await self.resource(ref.id)).revision
129
+ row = self.one("SELECT body FROM revisions WHERE id=? AND resource_id=?", (rid, ref.id))
130
+ require(row is not None, "revision_not_found")
131
+ return decode(Revision, loads(row[0]))
132
+
133
+ async def history(self, id, cursor=None, limit=50):
134
+ require(1 <= limit <= 500, "invalid_limit")
135
+ rows = self.rows("SELECT body FROM revisions WHERE resource_id=? AND id>? ORDER BY id LIMIT ?",
136
+ (id, cursor or "", limit+1))
137
+ values = tuple(decode(Revision, loads(r[0])) for r in rows[:limit])
138
+ return Page(items=values, next_cursor=values[-1].id if len(rows)>limit else None)
139
+
140
+ async def insert(self, resource):
141
+ self.check(True)
142
+ if resource.parent is not None:
143
+ await self.resource(resource.parent)
144
+ data = wire(resource)
145
+ self.execute("INSERT INTO resources VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (
146
+ resource.id, resource.type, resource.name, resource.parent, resource.owner,
147
+ resource.group, resource.mode, resource.generation, resource.revision,
148
+ resource.state, data['created_at'], data['modified_at'], canonical(resource).decode()), write=True)
149
+ for tag in resource.tags:
150
+ self.execute("INSERT INTO resource_tags VALUES (?,?)", (resource.id,tag), write=True)
151
+
152
+ async def replace(self, resource, expected_generation):
153
+ self.check(True)
154
+ old = await self.resource(resource.id)
155
+ require(old.generation == expected_generation, "generation_conflict",
156
+ details={"generation":old.generation, "revision":old.revision})
157
+ require(resource.generation == expected_generation + 1, "invalid_generation")
158
+ require(resource.type == old.type and resource.created_at == old.created_at and
159
+ resource.created_by == old.created_by, "immutable_creation_fact")
160
+ if resource.parent is not None:
161
+ require(resource.parent != resource.id, "parent_cycle")
162
+ parent = await self.resource(resource.parent)
163
+ require(all(a.id != resource.id for a in await self.ancestors(parent.id)), "parent_cycle")
164
+ if any(getattr(resource,k)!=getattr(old,k) for k in ('parent','owner','group','mode','state')):
165
+ self.set_setting('authorization_epoch',self.setting('authorization_epoch',0)+1)
166
+ data = wire(resource)
167
+ changed = self.execute("""UPDATE resources SET name=?,parent=?,owner=?,grp=?,mode=?,
168
+ generation=?,revision=?,state=?,modified_at=?,body=? WHERE id=? AND generation=?""",
169
+ (resource.name, resource.parent, resource.owner, resource.group, resource.mode,
170
+ resource.generation, resource.revision, resource.state, data['modified_at'],
171
+ canonical(resource).decode(), resource.id, expected_generation), write=True).rowcount
172
+ require(changed == 1, "generation_conflict")
173
+ if resource.tags != old.tags:
174
+ self.execute("DELETE FROM resource_tags WHERE resource_id=?", (resource.id,), write=True)
175
+ for tag in resource.tags:
176
+ self.execute("INSERT INTO resource_tags VALUES (?,?)", (resource.id,tag), write=True)
177
+
178
+ async def append_revision(self, revision):
179
+ self.check(True)
180
+ for parent in revision.parents:
181
+ await self.revision(ResourceRef(id=revision.resource_id, revision=parent))
182
+ self.execute("INSERT INTO revisions VALUES (?,?,?,?)", (
183
+ revision.id, revision.resource_id, wire(revision.created_at), canonical(revision).decode()), write=True)
184
+ for relation in revision.relations:
185
+ self.execute("INSERT INTO relations VALUES (?,?,?,?,?,?)", (
186
+ revision.id, revision.resource_id, relation.type, relation.target.id,
187
+ relation.target.revision, canonical(relation).decode()), write=True)
188
+
189
+ async def subject(self, id):
190
+ row = self.one("SELECT body FROM identities WHERE id=? AND kind='subject'", (id,))
191
+ require(row is not None, "subject_not_found")
192
+ return decode(Subject, loads(row[0]))
193
+
194
+ async def organization(self, id):
195
+ row = self.one("SELECT body FROM identities WHERE id=? AND kind='organization'", (id,))
196
+ require(row is not None, "group_not_found")
197
+ return decode(Organization, loads(row[0]))
198
+
199
+ async def credential(self, id):
200
+ row = self.one("SELECT body FROM credentials WHERE id=?", (id,))
201
+ require(row is not None, "credential_not_found")
202
+ return decode(Credential, loads(row[0]))
203
+
204
+ async def certificate(self, id):
205
+ row = self.one("SELECT body FROM certificates WHERE id=?", (id,))
206
+ require(row is not None, "certificate_not_found")
207
+ return decode(Certificate, loads(row[0]))
208
+
209
+ async def certificate_revoked(self, id):
210
+ row = self.one("SELECT revoked FROM certificates WHERE id=?", (id,))
211
+ require(row is not None, "certificate_not_found")
212
+ return bool(row[0])
213
+
214
+ async def memberships(self, subject):
215
+ if subject is None:
216
+ return ()
217
+ rows=self.rows("""SELECT m.org,m.body FROM memberships m
218
+ JOIN resources r ON r.id=m.org
219
+ WHERE m.subject=? AND r.type='organization' AND r.state='active'
220
+ ORDER BY m.org""",(subject,))
221
+ memberships=((org,decode(Membership,loads(body))) for org,body in rows)
222
+ # A stale membership row must not revive an archived/missing organization
223
+ # or borrow an organization/subject identifier from a mismatched body.
224
+ active=tuple(member for org,member in memberships if member.status=='active'
225
+ and member.organization_id==org and member.subject_id==subject
226
+ and org!='g_public')
227
+ row=self.one("SELECT body FROM identities WHERE id=? AND kind='subject'",(subject,))
228
+ if row is None:
229
+ return active
230
+ identity=decode(Subject,loads(row[0]))
231
+ if identity.kind in {'registered','custodial','system'} and not identity.local_only:
232
+ # /&public is a virtual group for formal subjects. Historical rows
233
+ # may remain in storage, but never create a duplicate authorization.
234
+ return (Membership(organization_id='g_public',subject_id=subject,
235
+ role='member',version=0),*active)
236
+ return active
237
+
238
+ async def request_result(self, subject, id, digest):
239
+ row = self.one("SELECT digest,body FROM results WHERE subject=? AND request_id=?", (subject,id))
240
+ if row is None:
241
+ return None
242
+ require(row[0] == digest, "idempotency_conflict")
243
+ return decode(OperationResult, loads(row[1]))
244
+
245
+ async def save_result(self, subject, digest, result):
246
+ from msg.storage.capacity import require_result_capacity
247
+ require_result_capacity(self)
248
+ self.execute("INSERT INTO results VALUES (?,?,?,?)", (
249
+ subject, result.request_id, digest, canonical(result).decode()), write=True)
250
+
251
+ async def update_identity(self, record, expected_generation):
252
+ self.check(True)
253
+ if isinstance(record, Membership):
254
+ self.set_setting('authorization_epoch',self.setting('authorization_epoch',0)+1)
255
+ table, where, args = 'memberships', 'org=? AND subject=?', (record.organization_id,record.subject_id)
256
+ generation = record.version
257
+ elif isinstance(record, EmailSettings):
258
+ table, where, args = 'emails', 'subject=?', (record.subject_id,)
259
+ generation = expected_generation + 1
260
+ else:
261
+ table, where, args = 'identities', 'id=?', (record.resource_id,)
262
+ generation = record.auth_version if isinstance(record, Subject) else record.membership_version
263
+ row = self.one(f"SELECT generation FROM {table} WHERE {where}", args)
264
+ require((row is None and expected_generation == -1) or
265
+ (row is not None and row[0] == expected_generation), "generation_conflict")
266
+ payload = canonical(record).decode()
267
+ if row is None:
268
+ if table == 'identities':
269
+ self.execute("INSERT INTO identities VALUES (?,?,?,?)", (*args,
270
+ 'subject' if isinstance(record,Subject) else 'organization', generation,payload), write=True)
271
+ elif table == 'memberships':
272
+ self.execute("INSERT INTO memberships VALUES (?,?,?,?)", (*args,generation,payload), write=True)
273
+ else:
274
+ self.execute("INSERT INTO emails VALUES (?,?,?)", (*args,generation,payload), write=True)
275
+ else:
276
+ self.execute(f"UPDATE {table} SET generation=?,body=? WHERE {where}",
277
+ (generation,payload,*args), write=True)
278
+
279
+ async def save_credential(self, credential, expected_auth_version):
280
+ subject = await self.subject(credential.subject_id)
281
+ require(subject.auth_version == expected_auth_version, "auth_version_conflict")
282
+ old=self.one("SELECT body FROM credentials WHERE id=?",(credential.id,))
283
+ if old is not None:
284
+ previous=decode(Credential,loads(old[0]))
285
+ require(previous.subject_id==credential.subject_id and previous.kind==credential.kind and
286
+ previous.verifier==credential.verifier,"credential_identity_immutable")
287
+ self.execute("INSERT INTO credentials VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body",
288
+ (credential.id,credential.subject_id,canonical(credential).decode()),write=True)
289
+
290
+ async def csr(self, id):
291
+ row = self.one("SELECT body FROM csrs WHERE id=?",(id,))
292
+ require(row is not None,"csr_not_found")
293
+ return decode(CertificateRequest,loads(row[0]))
294
+
295
+ async def csr_state(self, id):
296
+ row=self.one("SELECT state_body FROM csrs WHERE id=?",(id,))
297
+ require(row is not None,"csr_not_found")
298
+ return decode(CertificateRequestState,loads(row[0]))
299
+
300
+ async def save_csr(self, request):
301
+ state=CertificateRequestState(request_id=request.resource_id,status='pending',generation=0)
302
+ self.execute("INSERT INTO csrs VALUES (?,?,?,?,?)",(request.resource_id,0,'pending',
303
+ canonical(request).decode(),canonical(state).decode()),write=True)
304
+
305
+ async def transition_csr(self, state, expected_generation):
306
+ require(state.generation == expected_generation+1,"invalid_generation")
307
+ old=await self.csr_state(state.request_id)
308
+ require(old.status=='pending',"csr_not_pending")
309
+ require(old.generation==expected_generation,"generation_conflict")
310
+ self.execute("UPDATE csrs SET generation=?,state=?,state_body=? WHERE id=? AND generation=?",
311
+ (state.generation,state.status,canonical(state).decode(),state.request_id,expected_generation),write=True)
312
+
313
+ async def register_certificate(self, certificate, csr_id, expected_generation):
314
+ self.execute("INSERT INTO certificates VALUES (?,?,?,?,?)",(
315
+ certificate.resource_id,certificate.subject_id,certificate.parent_certificate_id,0,
316
+ canonical(certificate).decode()),write=True)
317
+ if csr_id is not None:
318
+ self.set_setting('certificate_request:'+certificate.resource_id,csr_id)
319
+ await self.transition_csr(CertificateRequestState(request_id=csr_id,status='issued',
320
+ generation=expected_generation+1,certificate_id=certificate.resource_id),expected_generation)
321
+
322
+ async def revoke_certificate(self, id, event):
323
+ await self.certificate(id)
324
+ self.execute("UPDATE certificates SET revoked=1 WHERE id=?",(id,),write=True)
325
+ self.set_setting('authorization_epoch',self.setting('authorization_epoch',0)+1)
326
+ await self.append_audit(event)
327
+
328
+ async def transfer(self,id):
329
+ row=self.one("SELECT body FROM transfers WHERE id=?",(id,))
330
+ require(row is not None,"transfer_not_found")
331
+ return decode(TransferSession,loads(row[0]))
332
+
333
+ async def save_transfer(self,session,expected_generation):
334
+ if expected_generation is None:
335
+ self.execute("INSERT INTO transfers (id,subject,generation,body) VALUES (?,?,?,?)",
336
+ (session.id,session.subject_id,session.generation,canonical(session).decode()),write=True)
337
+ else:
338
+ require(session.generation==expected_generation+1,"invalid_generation")
339
+ count=self.execute("UPDATE transfers SET generation=?,body=? WHERE id=? AND generation=?",
340
+ (session.generation,canonical(session).decode(),session.id,expected_generation),write=True).rowcount
341
+ require(count==1,"generation_conflict")
342
+
343
+ async def put_chunk(self,chunk):
344
+ self.check(True)
345
+ start,end=chunk.offset,chunk.offset+chunk.content.size
346
+ rows=self.rows("SELECT offset,length,body FROM chunks WHERE transfer_id=? AND offset<? AND offset+length>?",
347
+ (chunk.transfer_id,end,start))
348
+ if rows:
349
+ require(len(rows)==1 and rows[0][0]==start and rows[0][1]==chunk.content.size and
350
+ decode(TransferChunk,loads(rows[0][2])).content==chunk.content,"chunk_conflict")
351
+ return
352
+ self.execute("INSERT INTO chunks VALUES (?,?,?,?)",(chunk.transfer_id,start,chunk.content.size,
353
+ canonical(chunk).decode()),write=True)
354
+
355
+ async def missing_ranges(self,id,cursor=None,limit=50):
356
+ session=await self.transfer(id)
357
+ require(session.expected_size is not None,"size_required")
358
+ require(1<=limit<=500,"invalid_limit")
359
+ start=int(cursor or 0)
360
+ require(0<=start<=session.expected_size,"invalid_cursor")
361
+ gaps=[]
362
+ for row in self.execute("SELECT offset,length FROM chunks WHERE transfer_id=? AND offset+length>? ORDER BY offset",(id,start)):
363
+ if row[0]>start:
364
+ gaps.append((start,row[0]))
365
+ if len(gaps)>limit:
366
+ return Page(items=tuple(gaps[:limit]),next_cursor=str(gaps[limit][0]))
367
+ start=max(start,row[0]+row[1])
368
+ if start<session.expected_size:
369
+ gaps.append((start,session.expected_size))
370
+ return Page(items=tuple(gaps[:limit]),next_cursor=str(gaps[limit][0]) if len(gaps)>limit else None)
371
+
372
+ async def append_event(self,event):
373
+ self.execute("INSERT INTO events (id,body) VALUES (?,?)",(event.id,canonical(event).decode()),write=True)
374
+
375
+ async def append_audit(self,event):
376
+ previous=self.one("SELECT digest FROM audit ORDER BY seq DESC LIMIT 1")
377
+ prev=previous[0] if previous else None
378
+ data=wire(replace(event,previous_digest=prev,entry_digest=''))
379
+ data.pop('entry_digest')
380
+ entry=replace(event,previous_digest=prev,entry_digest=digest(data))
381
+ self.execute("INSERT INTO audit (digest,previous,body) VALUES (?,?,?)",
382
+ (entry.entry_digest,prev,canonical(entry).decode()),write=True)
383
+
384
+ async def enqueue(self,job):
385
+ self.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?)",(job.id,job.dedupe_key,job.kind,
386
+ job.state,wire(job.next_attempt_at),canonical(job).decode()),write=True)
387
+
388
+ async def job(self,id):
389
+ row=self.one("SELECT body FROM jobs WHERE id=?",(id,))
390
+ require(row is not None,'job_not_found')
391
+ return decode(EffectJob,loads(row[0]))
392
+
393
+ async def save_job(self,job):
394
+ self.execute("UPDATE jobs SET state=?,next_at=?,body=? WHERE id=?",(job.state,
395
+ wire(job.next_attempt_at),canonical(job).decode(),job.id),write=True)
396
+
397
+ def setting(self, key: str, default: SettingValue = None) -> SettingValue:
398
+ row=self.one("SELECT value FROM settings WHERE key=?",(key,))
399
+ return loads(row[0]) if row else default
400
+
401
+ def set_setting(self, key: str, value: SettingValue) -> None:
402
+ self.execute("INSERT INTO settings VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
403
+ (key,canonical(value).decode()),write=True)