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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/constants.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Protocol identities and protected bootstrap IDs, not application workflows."""
|
|
2
|
+
ROOT_SPACE='r_root'
|
|
3
|
+
ROOT_SUBJECT='u_root'
|
|
4
|
+
ONLINE_CA='u_online_ca'
|
|
5
|
+
PUBLIC_GROUP='g_public'
|
|
6
|
+
ADMINS_GROUP='g_admins'
|
|
7
|
+
CA_SPACE='t_ca'
|
|
8
|
+
CSR_SPACE='t_csr'
|
|
9
|
+
CERT_SPACE='t_certificates'
|
|
10
|
+
TOOLS_SPACE='t_tools'
|
|
11
|
+
PROTOCOL_VERSION=1
|
msg/core/__init__.py
ADDED
|
File without changes
|
msg/core/batching.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The one child-envelope validator for atomic and independent batches."""
|
|
2
|
+
from msg.core.codec import canonical
|
|
3
|
+
from msg.core.errors import require
|
|
4
|
+
from msg.core.packet import decode_packet
|
|
5
|
+
from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
|
|
6
|
+
|
|
7
|
+
# Credential delivery has response-only secrets; it requires its own call.
|
|
8
|
+
NO_BATCH=frozenset(SECRET_DELIVERY_MIN_VERSION) | {'identity.register','identity.upgrade'}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def packets(registry,request,subject,max_bytes=None):
|
|
12
|
+
max_bytes=max_bytes or len(canonical(request))
|
|
13
|
+
values=[]
|
|
14
|
+
for value in request.arguments['requests']:
|
|
15
|
+
packet=decode_packet(value,max_bytes)
|
|
16
|
+
spec=registry.operation(packet.operation,packet.contract_version)
|
|
17
|
+
require(spec.effect in {'read','transaction'} and not packet.operation.startswith('batch.')
|
|
18
|
+
and packet.operation not in NO_BATCH and 'network' in spec.entries,'operation_not_batchable')
|
|
19
|
+
require(packet.subject==subject,'batch_subject_mismatch')
|
|
20
|
+
values.append(packet)
|
|
21
|
+
require(len({p.request_id for p in values})==len(values),'duplicate_batch_request_id')
|
|
22
|
+
require(request.request_id not in {p.request_id for p in values},'recursive_request_id')
|
|
23
|
+
return values
|
msg/core/codec.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Versioned deterministic JSON, strict parsing and immutable contract records.
|
|
2
|
+
|
|
3
|
+
Canonical v1 uses UTF-8, sorted Unicode keys, compact separators and JSON finite
|
|
4
|
+
numbers as emitted by Python. It is not advertised as RFC 8785/JCS. Signed
|
|
5
|
+
request test vectors define the wire representation for non-Python clients.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import dataclasses
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
import types
|
|
16
|
+
import typing
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from .errors import Failure, require
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def freeze_json(value):
|
|
26
|
+
if value is None or type(value) in (bool, int, str):
|
|
27
|
+
return value
|
|
28
|
+
if type(value) is float:
|
|
29
|
+
require(math.isfinite(value), "non_finite_float")
|
|
30
|
+
return value
|
|
31
|
+
if isinstance(value, (tuple, list)):
|
|
32
|
+
return tuple(freeze_json(x) for x in value)
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
require(all(type(k) is str for k in value), "non_string_json_key")
|
|
35
|
+
return MappingProxyType({k: freeze_json(v) for k, v in value.items()})
|
|
36
|
+
raise Failure("unsupported_json_type")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def b64(data: bytes) -> str:
|
|
40
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unb64(value: str, *, limit: int = 16 * 1024 * 1024) -> bytes:
|
|
44
|
+
require(type(value) is str and len(value) <= (limit * 4 // 3 + 4), "invalid_base64")
|
|
45
|
+
require(bool(re.fullmatch(r"[A-Za-z0-9_-]*", value)), "invalid_base64")
|
|
46
|
+
try:
|
|
47
|
+
result = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
48
|
+
except (ValueError, TypeError) as exc:
|
|
49
|
+
raise Failure("invalid_base64") from exc
|
|
50
|
+
require(b64(result) == value and len(result) <= limit, "invalid_base64")
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def timestamp(value: datetime) -> str:
|
|
55
|
+
require(isinstance(value, datetime) and value.tzinfo is not None and
|
|
56
|
+
value.utcoffset() is not None, "naive_datetime")
|
|
57
|
+
return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_time(value: str) -> datetime:
|
|
61
|
+
require(type(value) is str and value.endswith("Z"), "invalid_datetime")
|
|
62
|
+
try:
|
|
63
|
+
result = datetime.fromisoformat(value)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise Failure("invalid_datetime") from exc
|
|
66
|
+
require(result.tzinfo is not None, "naive_datetime")
|
|
67
|
+
return result.astimezone(UTC)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wire(value, *, compact: bool = False):
|
|
71
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
72
|
+
result = {}
|
|
73
|
+
for field in dataclasses.fields(value):
|
|
74
|
+
v = getattr(value, field.name)
|
|
75
|
+
if v is None and field.metadata.get('omit_if_none'):
|
|
76
|
+
continue
|
|
77
|
+
if compact and (v is None or v == () or v == frozenset()):
|
|
78
|
+
continue
|
|
79
|
+
result[field.name] = f"{v:04o}" if field.name == "mode" else wire(v, compact=compact)
|
|
80
|
+
return result
|
|
81
|
+
if isinstance(value, Mapping):
|
|
82
|
+
return {k: wire(v, compact=compact) for k, v in value.items()
|
|
83
|
+
if not compact or v is not None}
|
|
84
|
+
if isinstance(value, (tuple, list)):
|
|
85
|
+
return [wire(v, compact=compact) for v in value]
|
|
86
|
+
if isinstance(value, (set, frozenset)):
|
|
87
|
+
return sorted((wire(v, compact=compact) for v in value), key=canonical)
|
|
88
|
+
if isinstance(value, datetime):
|
|
89
|
+
return timestamp(value)
|
|
90
|
+
if isinstance(value, bytes):
|
|
91
|
+
return b64(value)
|
|
92
|
+
if isinstance(value, Path):
|
|
93
|
+
return str(value)
|
|
94
|
+
if value is None or type(value) in (bool, int, str):
|
|
95
|
+
return value
|
|
96
|
+
if type(value) is float and math.isfinite(value):
|
|
97
|
+
return value
|
|
98
|
+
raise Failure("invalid_json_value")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def canonical(value) -> bytes:
|
|
102
|
+
try:
|
|
103
|
+
return json.dumps(wire(value), ensure_ascii=False, sort_keys=True,
|
|
104
|
+
separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
105
|
+
except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
|
|
106
|
+
if isinstance(exc, Failure):
|
|
107
|
+
raise
|
|
108
|
+
raise Failure("invalid_json_value") from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def digest(value: bytes | object) -> str:
|
|
112
|
+
return "sha256:" + hashlib.sha256(value if isinstance(value, bytes) else canonical(value)).hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def loads(raw: str | bytes):
|
|
116
|
+
def pairs(items):
|
|
117
|
+
result = {}
|
|
118
|
+
for key, value in items:
|
|
119
|
+
require(key not in result, "duplicate_json_key", key)
|
|
120
|
+
result[key] = value
|
|
121
|
+
return result
|
|
122
|
+
def constant(_):
|
|
123
|
+
raise Failure("non_finite_float")
|
|
124
|
+
try:
|
|
125
|
+
value = json.loads(raw, object_pairs_hook=pairs, parse_constant=constant)
|
|
126
|
+
freeze_json(value)
|
|
127
|
+
return value
|
|
128
|
+
except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
|
|
129
|
+
if isinstance(exc, Failure):
|
|
130
|
+
raise
|
|
131
|
+
raise Failure("invalid_json") from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@lru_cache(maxsize=256)
|
|
135
|
+
def _record_shape(cls):
|
|
136
|
+
# Registered Python classes are immutable schema definitions. This cache
|
|
137
|
+
# never contains user identities, authorization decisions, or mutable rows.
|
|
138
|
+
return ({f.name: f for f in dataclasses.fields(cls)}, typing.get_type_hints(cls))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def decode(cls, value, field: str = ""):
|
|
142
|
+
"""Decode only declared fields; booleans never masquerade as integers."""
|
|
143
|
+
if isinstance(cls, typing.TypeAliasType):
|
|
144
|
+
if cls.__name__ in {"Json", "JsonMap"}:
|
|
145
|
+
if cls.__name__ == "JsonMap":
|
|
146
|
+
require(isinstance(value, Mapping), "invalid_type", field)
|
|
147
|
+
return freeze_json(value)
|
|
148
|
+
return decode(cls.__value__, value, field)
|
|
149
|
+
if hasattr(cls, "__supertype__"):
|
|
150
|
+
return decode(cls.__supertype__, value, field)
|
|
151
|
+
origin, args = typing.get_origin(cls), typing.get_args(cls)
|
|
152
|
+
if origin in (typing.Union, types.UnionType):
|
|
153
|
+
for option in args:
|
|
154
|
+
try:
|
|
155
|
+
return decode(option, value, field)
|
|
156
|
+
except (Failure, TypeError, ValueError):
|
|
157
|
+
continue
|
|
158
|
+
raise Failure("invalid_union", field)
|
|
159
|
+
if origin is typing.Literal:
|
|
160
|
+
require(any(type(value) is type(v) and value == v for v in args), "invalid_enum", field)
|
|
161
|
+
return value
|
|
162
|
+
if origin in (tuple, list, frozenset, set):
|
|
163
|
+
require(isinstance(value, (tuple, list, set, frozenset)), "invalid_type", field)
|
|
164
|
+
if origin is tuple and args and args[-1] is not Ellipsis:
|
|
165
|
+
require(len(args) == len(value), "invalid_length", field)
|
|
166
|
+
return tuple(decode(t, x, field) for t, x in zip(args, value, strict=True))
|
|
167
|
+
child = args[0] if args else typing.Any
|
|
168
|
+
return origin(decode(child, x, field) for x in value)
|
|
169
|
+
if origin in (dict, Mapping):
|
|
170
|
+
require(isinstance(value, Mapping), "invalid_type", field)
|
|
171
|
+
return MappingProxyType({decode(args[0], k, field): decode(args[1], v, field)
|
|
172
|
+
for k, v in value.items()})
|
|
173
|
+
if cls is datetime:
|
|
174
|
+
return parse_time(value)
|
|
175
|
+
if cls is bytes:
|
|
176
|
+
return unb64(value)
|
|
177
|
+
if cls is Path:
|
|
178
|
+
require(type(value) is str, "invalid_type", field)
|
|
179
|
+
return Path(value)
|
|
180
|
+
if cls is typing.Any:
|
|
181
|
+
return freeze_json(value)
|
|
182
|
+
if dataclasses.is_dataclass(cls):
|
|
183
|
+
require(isinstance(value, Mapping), "invalid_type", field)
|
|
184
|
+
fields, hints = _record_shape(cls)
|
|
185
|
+
unknown = set(value) - fields.keys()
|
|
186
|
+
require(not unknown, "unknown_field", (field + "." if field else "") +
|
|
187
|
+
(sorted(unknown)[0] if unknown else ""))
|
|
188
|
+
result = {}
|
|
189
|
+
for name, f in fields.items():
|
|
190
|
+
where = f"{field}.{name}" if field else name
|
|
191
|
+
if name not in value:
|
|
192
|
+
require(f.default is not dataclasses.MISSING or
|
|
193
|
+
f.default_factory is not dataclasses.MISSING, "missing_field", where)
|
|
194
|
+
continue
|
|
195
|
+
if name == "mode":
|
|
196
|
+
mode = value[name]
|
|
197
|
+
require(type(mode) is str and re.fullmatch(r"[0-7]{4}", mode) is not None,
|
|
198
|
+
"invalid_mode", where)
|
|
199
|
+
result[name] = int(mode, 8)
|
|
200
|
+
else:
|
|
201
|
+
result[name] = decode(hints[name], value[name], where)
|
|
202
|
+
return cls(**result)
|
|
203
|
+
if cls in (str, bool, int, float, type(None)):
|
|
204
|
+
require(type(value) is cls, "invalid_type", field)
|
|
205
|
+
if cls is float:
|
|
206
|
+
require(math.isfinite(value), "non_finite_float", field)
|
|
207
|
+
return value
|
|
208
|
+
raise Failure("unsupported_contract_type", field)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_record(obj):
|
|
212
|
+
for f in dataclasses.fields(obj):
|
|
213
|
+
value = getattr(obj, f.name)
|
|
214
|
+
if isinstance(value, datetime):
|
|
215
|
+
require(value.tzinfo is not None and value.utcoffset() is not None, "naive_datetime", f.name)
|
|
216
|
+
object.__setattr__(obj, f.name, value.astimezone(UTC))
|
|
217
|
+
elif isinstance(value, Mapping):
|
|
218
|
+
object.__setattr__(obj, f.name, freeze_json(value))
|
|
219
|
+
elif isinstance(value, list):
|
|
220
|
+
object.__setattr__(obj, f.name, tuple(value))
|
|
221
|
+
elif isinstance(value, set):
|
|
222
|
+
object.__setattr__(obj, f.name, frozenset(value))
|
|
223
|
+
if f.name in {"generation", "size", "expected_size", "offset", "attempts", "delegation_depth",
|
|
224
|
+
"max_child_ca_depth", "max_delegation_depth", "max_redirects"} and value is not None:
|
|
225
|
+
require(type(value) is int and value >= 0, "invalid_nonnegative_integer", f.name)
|
|
226
|
+
if f.name in {"version", "type_version", "format_version", "protocol_version", "contract_version",
|
|
227
|
+
"max_cert_ttl_seconds", "requested_ttl_seconds"}:
|
|
228
|
+
if f.name == "version" and type(obj).__name__ == "PluginManifest":
|
|
229
|
+
require(type(value) is str and bool(value), "invalid_version", f.name)
|
|
230
|
+
elif f.name == "version" and type(obj).__name__ == "Membership":
|
|
231
|
+
require(type(value) is int and value >= 0, "invalid_version", f.name)
|
|
232
|
+
else:
|
|
233
|
+
require(type(value) is int and value >= 1, "invalid_version", f.name)
|
|
234
|
+
if type(obj).__name__ == "Resource":
|
|
235
|
+
from msg.core.tags import normalize_tags
|
|
236
|
+
|
|
237
|
+
require(type(obj.mode) is int and 0 <= obj.mode <= 0o7777, "invalid_mode")
|
|
238
|
+
require(obj.state in {"active", "archived", "purged"}, "invalid_state")
|
|
239
|
+
require(obj.parent != obj.id, "parent_cycle")
|
|
240
|
+
require(obj.tags == normalize_tags(obj.tags), "invalid_tags")
|
|
241
|
+
if type(obj).__name__ == "Relation" and obj.excerpt is not None:
|
|
242
|
+
require(len(obj.excerpt) == 2 and all(type(n) is int for n in obj.excerpt)
|
|
243
|
+
and 0 <= obj.excerpt[0] <= obj.excerpt[1], "invalid_byte_range")
|
|
244
|
+
if hasattr(obj, "not_before") and getattr(obj, "expires_at", None) is not None:
|
|
245
|
+
require(obj.not_before < obj.expires_at, "invalid_validity")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def record(**kwargs):
|
|
249
|
+
"""Dataclass decoration plus local validation, not a domain base class."""
|
|
250
|
+
def decorate(cls):
|
|
251
|
+
cls.__post_init__ = validate_record
|
|
252
|
+
return dataclasses.dataclass(**kwargs)(cls)
|
|
253
|
+
return decorate
|
msg/core/contracts.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import *
|
|
4
|
+
from .query import QuerySession
|
|
5
|
+
from .tool_execution import ToolRunner
|
|
6
|
+
|
|
7
|
+
class MetadataSession(QuerySession, Protocol):
|
|
8
|
+
def on_rollback(self, effect: Callable[[], Awaitable[None]]) -> None: ...
|
|
9
|
+
async def resource(self, id: ResourceId) -> Resource: ...
|
|
10
|
+
async def resolve(self, path: str) -> ResourceId: ...
|
|
11
|
+
async def path(self, id: ResourceId) -> str: ...
|
|
12
|
+
async def ancestors(self, id: ResourceId) -> tuple[Resource, ...]: ...
|
|
13
|
+
async def children(
|
|
14
|
+
self, parent: ResourceId, cursor: str | None, limit: int
|
|
15
|
+
) -> Page[Resource]: ...
|
|
16
|
+
async def revision(self, ref: ResourceRef) -> Revision: ...
|
|
17
|
+
async def history(
|
|
18
|
+
self, id: ResourceId, cursor: str | None, limit: int
|
|
19
|
+
) -> Page[Revision]: ...
|
|
20
|
+
async def insert(self, resource: Resource) -> None: ...
|
|
21
|
+
async def replace(
|
|
22
|
+
self, resource: Resource, expected_generation: int
|
|
23
|
+
) -> None: ...
|
|
24
|
+
async def append_revision(self, revision: Revision) -> None: ...
|
|
25
|
+
async def subject(self, id: ResourceId) -> Subject: ...
|
|
26
|
+
async def organization(self, id: ResourceId) -> Organization: ...
|
|
27
|
+
async def credential(self, id: ID) -> Credential: ...
|
|
28
|
+
async def certificate(self, id: ResourceId) -> Certificate: ...
|
|
29
|
+
async def certificate_revoked(self, id: ResourceId) -> bool: ...
|
|
30
|
+
async def memberships(
|
|
31
|
+
self, subject: ResourceId
|
|
32
|
+
) -> tuple[Membership, ...]: ...
|
|
33
|
+
async def request_result(
|
|
34
|
+
self, subject: ResourceId, id: RequestId, digest: Digest
|
|
35
|
+
) -> OperationResult | None: ...
|
|
36
|
+
async def save_result(
|
|
37
|
+
self, subject: ResourceId, digest: Digest, result: OperationResult
|
|
38
|
+
) -> None: ...
|
|
39
|
+
async def update_identity(
|
|
40
|
+
self, record: Subject | Organization | Membership | EmailSettings,
|
|
41
|
+
expected_generation: int
|
|
42
|
+
) -> None: ...
|
|
43
|
+
async def save_credential(
|
|
44
|
+
self, credential: Credential, expected_auth_version: int
|
|
45
|
+
) -> None: ...
|
|
46
|
+
async def csr(self, id: ResourceId) -> CertificateRequest: ...
|
|
47
|
+
async def csr_state(
|
|
48
|
+
self, id: ResourceId
|
|
49
|
+
) -> CertificateRequestState: ...
|
|
50
|
+
async def save_csr(
|
|
51
|
+
self, request: CertificateRequest
|
|
52
|
+
) -> None: ...
|
|
53
|
+
async def transition_csr(
|
|
54
|
+
self, state: CertificateRequestState, expected_generation: int
|
|
55
|
+
) -> None: ...
|
|
56
|
+
async def revoke_certificate(
|
|
57
|
+
self, id: ResourceId, event: AuditEvent
|
|
58
|
+
) -> None: ...
|
|
59
|
+
async def register_certificate(
|
|
60
|
+
self, certificate: Certificate, csr_id: ResourceId,
|
|
61
|
+
expected_generation: int
|
|
62
|
+
) -> None: ...
|
|
63
|
+
async def transfer(self, id: ID) -> TransferSession: ...
|
|
64
|
+
async def save_transfer(
|
|
65
|
+
self, session: TransferSession, expected_generation: int | None
|
|
66
|
+
) -> None: ...
|
|
67
|
+
async def put_chunk(self, chunk: TransferChunk) -> None: ...
|
|
68
|
+
async def missing_ranges(
|
|
69
|
+
self, id: ID, cursor: str | None, limit: int
|
|
70
|
+
) -> Page[ByteRange]: ...
|
|
71
|
+
async def append_event(self, event: Event) -> None: ...
|
|
72
|
+
async def append_audit(self, event: AuditEvent) -> None: ...
|
|
73
|
+
async def enqueue(self, job: EffectJob) -> None: ...
|
|
74
|
+
async def job(self, id: ID) -> EffectJob: ...
|
|
75
|
+
async def save_job(self, job: EffectJob) -> None: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MetadataStore(Protocol):
|
|
79
|
+
def transaction(
|
|
80
|
+
self, *, write: bool
|
|
81
|
+
) -> AbstractAsyncContextManager[MetadataSession]: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ContentStore(Protocol):
|
|
85
|
+
async def put(
|
|
86
|
+
self, chunks: AsyncIterator[bytes], media_type: str,
|
|
87
|
+
expected_digest: Digest | None = None
|
|
88
|
+
) -> BlobRef: ...
|
|
89
|
+
def read(
|
|
90
|
+
self, blob: BlobRef, byte_range: ByteRange | None = None
|
|
91
|
+
) -> AsyncIterator[bytes]: ...
|
|
92
|
+
async def pin(self, blob: BlobRef, lease_id: ID) -> None: ...
|
|
93
|
+
async def unpin(self, blob: BlobRef, lease_id: ID) -> None: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Authenticator(Protocol):
|
|
97
|
+
async def authenticate(
|
|
98
|
+
self, request: OperationRequest, session: MetadataSession,
|
|
99
|
+
*, entry: Entry
|
|
100
|
+
) -> Principal: ...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Authorizer(Protocol):
|
|
104
|
+
async def require(
|
|
105
|
+
self, context: ExecutionContext, request: OperationRequest,
|
|
106
|
+
checks: tuple[AccessRequirement, ...], session: MetadataSession
|
|
107
|
+
) -> tuple[ResourceRef, ...]: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Signer(Protocol):
|
|
111
|
+
@property
|
|
112
|
+
def key_id(self) -> ID: ...
|
|
113
|
+
def sign(self, payload: bytes, *, purpose: str) -> Signature: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ClientTransport(Protocol):
|
|
117
|
+
async def discover(self) -> TransportLimits: ...
|
|
118
|
+
async def call(self, request: OperationRequest) -> OperationResult: ...
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Compatibility import only. The unused invoke -> ResourceRef declaration never
|
|
122
|
+
# described the injected runner. ToolRunner is now the one actual execution port;
|
|
123
|
+
# publication and current-authority/attempt checks remain owned by EffectWorker.
|
|
124
|
+
ToolExecutor = ToolRunner
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NotificationSender(Protocol):
|
|
128
|
+
async def send(self, job: EffectJob) -> Literal['sent', 'uncertain']: ...
|
msg/core/cursors.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Opaque position cursors are not authentication credentials."""
|
|
2
|
+
import hmac
|
|
3
|
+
from msg.core.codec import b64,unb64,canonical,loads,digest,parse_time,wire
|
|
4
|
+
from msg.core.errors import require
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CursorCodec:
|
|
8
|
+
def __init__(self,key):
|
|
9
|
+
self.key=key
|
|
10
|
+
|
|
11
|
+
def encode(self,kind,query,position):
|
|
12
|
+
data=canonical({'v':1,'kind':kind,'query':query,'position':position})
|
|
13
|
+
tag=hmac.digest(self.key,b'cursor-v1\0'+data,'sha256')
|
|
14
|
+
return b64(data)+'.'+b64(tag)
|
|
15
|
+
|
|
16
|
+
def inspect(self,value):
|
|
17
|
+
require(isinstance(value,str) and len(value)<=8192 and value.count('.')==1,'invalid_cursor')
|
|
18
|
+
encoded,signature=value.split('.')
|
|
19
|
+
raw=unb64(encoded)
|
|
20
|
+
require(hmac.compare_digest(hmac.digest(self.key,b'cursor-v1\0'+raw,'sha256'),unb64(signature)),'invalid_cursor')
|
|
21
|
+
data=loads(raw)
|
|
22
|
+
require(data.get('v')==1,'invalid_cursor')
|
|
23
|
+
return data
|
|
24
|
+
|
|
25
|
+
def decode(self,value,kind,query):
|
|
26
|
+
data=self.inspect(value)
|
|
27
|
+
require(data.get('v')==1 and data.get('kind')==kind and data.get('query')==query,'cursor_query_mismatch')
|
|
28
|
+
return data['position']
|
|
29
|
+
|
|
30
|
+
def encode_page(self,operation,arguments,position,snapshot,principal,expires_at):
|
|
31
|
+
query={'operation':operation,'arguments':arguments}
|
|
32
|
+
return self.encode('read-page',query,{'last':position,'snapshot':wire(snapshot),
|
|
33
|
+
'principal':principal,'expires_at':wire(expires_at),'query_digest':digest(query)})
|
|
34
|
+
|
|
35
|
+
def inspect_page(self,value,now):
|
|
36
|
+
data=self.inspect(value)
|
|
37
|
+
require(data.get('kind')=='read-page','cursor_kind_mismatch')
|
|
38
|
+
position=data.get('position')
|
|
39
|
+
require(isinstance(position,dict) and position.get('query_digest')==digest(data.get('query')),
|
|
40
|
+
'invalid_cursor')
|
|
41
|
+
require(now<parse_time(position['expires_at']),'cursor_expired')
|
|
42
|
+
return data['query'],position
|
|
43
|
+
|
|
44
|
+
def decode_page(self,value,operation,arguments,principal,now):
|
|
45
|
+
query,position=self.inspect_page(value,now)
|
|
46
|
+
require(query=={'operation':operation,'arguments':arguments},'cursor_query_mismatch')
|
|
47
|
+
require(position['principal']==principal,'cursor_principal_mismatch')
|
|
48
|
+
return position['last'],parse_time(position['snapshot'])
|
|
49
|
+
|
|
50
|
+
def encode_read(self,ref,max_bytes,offset,fence,line_start,continued,principal,expires_at,
|
|
51
|
+
previous=None):
|
|
52
|
+
query={'ref':wire(ref),'max_bytes':max_bytes}
|
|
53
|
+
return self.encode('read-segment',query,{'offset':offset,'fence':fence,'line_start':line_start,
|
|
54
|
+
'continued':continued,'principal':principal,'expires_at':wire(expires_at),
|
|
55
|
+
'previous':previous})
|
|
56
|
+
|
|
57
|
+
def inspect_read(self,value,now):
|
|
58
|
+
data=self.inspect(value)
|
|
59
|
+
require(data.get('kind')=='read-segment','cursor_kind_mismatch')
|
|
60
|
+
position=data.get('position')
|
|
61
|
+
require(isinstance(position,dict) and now<parse_time(position['expires_at']),
|
|
62
|
+
'cursor_expired')
|
|
63
|
+
return data['query'],position
|
|
64
|
+
|
|
65
|
+
def decode_read(self,value,principal,now):
|
|
66
|
+
query,position=self.inspect_read(value,now)
|
|
67
|
+
require(position['principal']==principal,'cursor_principal_mismatch')
|
|
68
|
+
return query,position
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Validate one exact mailbox before binding or passing it to SMTP.
|
|
2
|
+
|
|
3
|
+
An email endpoint is an addr-spec, not a display name, group or address list.
|
|
4
|
+
Do not normalize it: the verified endpoint snapshot must identify the same bytes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from email import policy
|
|
9
|
+
from email.errors import HeaderParseError, NonASCIILocalPartDefect
|
|
10
|
+
from email.headerregistry import Address
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from msg.core.errors import Failure, require
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_address(address: str) -> Address:
|
|
17
|
+
require(isinstance(address, str) and len(address) <= 254 and
|
|
18
|
+
re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+', address) is not None and
|
|
19
|
+
not any(ord(c) < 32 or ord(c) == 127 for c in address), 'invalid_email')
|
|
20
|
+
try:
|
|
21
|
+
header = policy.default.header_factory('To', address)
|
|
22
|
+
mailboxes, groups = header.addresses, header.groups
|
|
23
|
+
except (HeaderParseError, ValueError, IndexError):
|
|
24
|
+
raise Failure('invalid_email') from None
|
|
25
|
+
# SMTPUTF8 mailboxes remain supported. All other parser defects, including
|
|
26
|
+
# obsolete or partially recovered syntax, fail closed rather than redirect.
|
|
27
|
+
require(len(mailboxes) == len(groups) == 1 and groups[0].display_name is None and
|
|
28
|
+
all(isinstance(d, NonASCIILocalPartDefect) for d in header.defects),
|
|
29
|
+
'invalid_email')
|
|
30
|
+
mailbox = mailboxes[0]
|
|
31
|
+
require(bool(mailbox.username and mailbox.domain) and mailbox.addr_spec == address,
|
|
32
|
+
'invalid_email')
|
|
33
|
+
return mailbox
|
msg/core/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Stable errors shared by business operations and every transport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Failure(ValueError):
|
|
6
|
+
def __init__(self, code: str, field: str | None = None, *, retryable: bool = False,
|
|
7
|
+
details: dict | None = None):
|
|
8
|
+
super().__init__(code)
|
|
9
|
+
self.code, self.field, self.retryable, self.details = code, field, retryable, details
|
|
10
|
+
|
|
11
|
+
def as_dict(self) -> dict:
|
|
12
|
+
value = {"code": self.code, "retryable": self.retryable}
|
|
13
|
+
if self.field:
|
|
14
|
+
value["field_path"] = self.field
|
|
15
|
+
if self.details:
|
|
16
|
+
value["details"] = self.details
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require(condition: bool, code: str, field: str | None = None, **kwargs) -> None:
|
|
21
|
+
if not condition:
|
|
22
|
+
raise Failure(code, field, **kwargs)
|
msg/core/events.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Fixed executor dependencies, not plugin hooks or a workflow interface.
|
|
2
|
+
|
|
3
|
+
ResultProjection is a permission-checked read in the existing transaction.
|
|
4
|
+
TransactionalEventNotifications may enqueue durable jobs in that transaction;
|
|
5
|
+
it must never perform external I/O or commit it. Execution, auth and receipt
|
|
6
|
+
ownership remain in OperationExecutor.
|
|
7
|
+
"""
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
from msg.core.contracts import MetadataSession
|
|
10
|
+
from msg.core.models import Event, ExecutionContext, JsonMap, OperationRequest, ResourceRef
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResultProjection(Protocol):
|
|
14
|
+
async def __call__(self, context: ExecutionContext, request: OperationRequest,
|
|
15
|
+
session: MetadataSession, resource: ResourceRef,
|
|
16
|
+
*, fields: tuple[str, ...]) -> JsonMap: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransactionalEventNotifications(Protocol):
|
|
20
|
+
async def __call__(self, session: MetadataSession, event: Event) -> None: ...
|