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
@@ -0,0 +1,387 @@
1
+ """Registry-derived, order-independent short codes for public GET paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ from importlib.resources import files
9
+ from urllib.parse import quote
10
+
11
+ from msg.core.codec import canonical, digest, loads
12
+ from msg.core.errors import Failure, require
13
+
14
+
15
+ _PREFIX = {"namespace": "n", "operation": "o", "field": "f", "enum": "e"}
16
+ _SCALAR = {"string", "integer", "number", "boolean"}
17
+ _DEFAULT_PUBLISHED = object()
18
+ _SECRET_OPERATIONS = frozenset({'identity.temporary', 'identity.custodial_create',
19
+ 'identity.token_create', 'identity.token_rotate', 'identity.token_recover',
20
+ 'sharing.link_read'})
21
+
22
+ # Versioned, published path grammar for bounded GET-only read queries. The
23
+ # meanings of these short segments must not change within version 1.
24
+ READ_QUERY_V1_SEGMENTS = {'root':'r','type':'t','sort':'s','fields':'f',
25
+ 'first':'n','after':'a'}
26
+ READ_QUERY_V2_SEGMENTS = {**READ_QUERY_V1_SEGMENTS,'expand':'x',
27
+ 'nested_first':'nf','collection':'co','parent':'pa','limit':'l'}
28
+ READ_QUERY_V3_SEGMENTS = {**READ_QUERY_V1_SEGMENTS,'enter':'x','leave':'up',
29
+ 'collection':'co','parent':'pa'}
30
+ READ_QUERY_V1_SORT = {'id':'i','time':'t','name':'n'}
31
+ READ_QUERY_V1_FIELDS = {'id':'i','type':'t','name':'n','revision':'v',
32
+ 'generation':'g','path':'p','created_at':'c',
33
+ 'modified_at':'m','owner':'o','group':'u','mode':'d'}
34
+ SEARCH_QUERY_V1_SEGMENTS = {'query':'q','tag':'t','limit':'n','cursor':'a'}
35
+
36
+
37
+ def read_query_path_document(registry):
38
+ require(registry.operation('discovery.read_query').effect=='read','effect_mismatch')
39
+ require(registry.operation('discovery.read_query',2).effect=='read','effect_mismatch')
40
+ require(registry.operation('discovery.read_query',3).effect=='read','effect_mismatch')
41
+ require(registry.operation('discovery.search').effect=='read','effect_mismatch')
42
+ return {'version':1,'available_versions':[1,2,3],
43
+ 'segments':READ_QUERY_V1_SEGMENTS,'segments_v2':READ_QUERY_V2_SEGMENTS,
44
+ 'segments_v3':READ_QUERY_V3_SEGMENTS,
45
+ 'sort':READ_QUERY_V1_SORT,'fields':READ_QUERY_V1_FIELDS,
46
+ 'search_segments':SEARCH_QUERY_V1_SEGMENTS,
47
+ 'read_template':'/_read/q/1/r/{percent-encoded-root}/t/{type}/s/{sort-code}/f/{field-codes}/n/{first}',
48
+ 'read_template_v2':'/_read/q/2/r/{percent-encoded-root}/x/{collections}/n/{first}/nf/{nested-first}',
49
+ 'read_template_v3':'/_r/q/3/r/{percent-encoded-root}/n/{first}/x/children/n/{nested-first}/up/1',
50
+ 'tree_rules':{'enter':'x/children or x/replies','leave':'up/1',
51
+ 'max_depth':4,'each_collection_has_page_info':True},
52
+ 'search_template':'/_search/q/1/q/{percent-encoded-query}/n/{limit}',
53
+ 'proof_suffix':'/p/{short-lived-signed-OperationRequest}',
54
+ 'continuation':'/_r/c/{opaque-cursor}',
55
+ 'query_ref':{'description':'Sealed read-only descriptor; never an authorization credential',
56
+ 'upload':'/-/g/transfer.open -> transfer.part_put -> transfer.seal',
57
+ 'seal':'/-/g/transfer.query_seal/j/{signed-packet}',
58
+ 'descriptor':{'version':1,'kind':'read','arguments':'{ReadQuery arguments}'},
59
+ 'read':'/_r/q/{query_ref}/p/{short-lived-signed-query_get-packet}'}}
60
+
61
+
62
+ def _code(kind: str, identity: str) -> str:
63
+ # A code depends only on its semantic identity, never registration order.
64
+ raw = hashlib.sha256(f"msg-dictionary-v1:{kind}:{identity}".encode()).digest()
65
+ return _PREFIX[kind] + base64.b32encode(raw).decode("ascii").lower()[:7]
66
+
67
+
68
+ def _field_type(schema: dict) -> str | list[str] | None:
69
+ return schema.get("type")
70
+
71
+
72
+ def _is_scalar(schema: dict) -> bool:
73
+ kind = _field_type(schema)
74
+ if isinstance(kind, str) and kind in _SCALAR:
75
+ return True
76
+ choices = schema.get("enum", [schema["const"]] if "const" in schema else [])
77
+ return bool(choices) and all(type(value) in {str, int, float, bool} for value in choices)
78
+
79
+
80
+ def _example(schema: dict) -> str:
81
+ if "enum" in schema:
82
+ return str(schema["enum"][0])
83
+ if "const" in schema:
84
+ return str(schema["const"])
85
+ match _field_type(schema):
86
+ case "boolean":
87
+ return "true"
88
+ case "integer" | "number":
89
+ return str(schema.get("minimum", 0))
90
+ case _:
91
+ return "example"
92
+
93
+
94
+ def _choices(schema: dict) -> list:
95
+ if "enum" in schema:
96
+ return schema["enum"]
97
+ if "const" in schema:
98
+ return [schema["const"]]
99
+ return []
100
+
101
+
102
+ class ShortCodeDictionary:
103
+ def __init__(self, registry, entry: str = "network", *, published: dict | None = None) -> None:
104
+ require(registry.frozen, "registry_not_frozen")
105
+ self.registry = registry
106
+ self.entry = entry
107
+ self._by_code: dict[str, dict[str, dict]] = {kind: {} for kind in _PREFIX}
108
+ self._by_identity: dict[str, dict[str, str]] = {kind: {} for kind in _PREFIX}
109
+ self._operations = {}
110
+ operations = []
111
+ namespaces = set()
112
+ specs = sorted(registry.operations(entry), key=lambda spec: (spec.name, spec.version))
113
+ for spec in specs:
114
+ identity = f"{spec.name}@{spec.version}"
115
+ namespace = spec.name.split(".", 1)[0]
116
+ namespaces.add(namespace)
117
+ description = registry.describe(spec)
118
+ replacement = description.get("replaced_by")
119
+ op_code = self._add(
120
+ "operation", identity, value=identity,
121
+ deprecated=description.get("deprecated", False),
122
+ replaced_by=(_code("operation", replacement) if replacement else None),
123
+ )
124
+ self._operations[op_code] = spec
125
+ schema = registry.schema(spec.input_schema)
126
+ properties = schema.get("properties", {})
127
+ required = list(schema.get("required", []))
128
+ fields = []
129
+ for order, (name, field_schema) in enumerate(properties.items()):
130
+ field_identity = f"{identity}:{name}"
131
+ field_code = self._add("field", field_identity, value=name,
132
+ operation=identity, path=name)
133
+ enums = []
134
+ for value in _choices(field_schema):
135
+ enum_identity = f"{field_identity}:{canonical(value).decode()}"
136
+ enum_code = self._add("enum", enum_identity, value=value,
137
+ operation=identity, path=name)
138
+ enums.append({"code": enum_code, "value": value})
139
+ fields.append({"name": name, "code": field_code,
140
+ "type": _field_type(field_schema), "required": name in required,
141
+ "order": required.index(name) if name in required else None,
142
+ "constraints": {key: value for key, value in field_schema.items()
143
+ if key not in {"type", "enum"}},
144
+ "enum": enums})
145
+ direct = (spec.name != "sharing.link_read" and spec.effect == "read" and
146
+ schema.get("type") == "object" and
147
+ all(name in properties and _is_scalar(properties[name]) for name in required))
148
+ if spec.name in _SECRET_OPERATIONS:
149
+ template = "/-/p/" + spec.name
150
+ example = None
151
+ elif direct:
152
+ template = "/-/g/" + op_code + "".join("/{" + name + "}" for name in required)
153
+ example = "/-/g/" + op_code + "".join(
154
+ "/" + quote(
155
+ self.code_for("enum", f"{identity}:{name}:"
156
+ f"{canonical(_choices(properties[name])[0]).decode()}")
157
+ if "enum" in properties[name] or "const" in properties[name]
158
+ else _example(properties[name]),
159
+ safe="") for name in required)
160
+ else:
161
+ template = "/-/g/" + spec.name + "/j/{packet}"
162
+ example = None
163
+ direct_write_template = None
164
+ direct_expected_template = None
165
+ legacy_direct_write = (spec.effect != 'read' and
166
+ schema.get('type') == 'object' and all(
167
+ name in properties and _is_scalar(properties[name]) for name in required))
168
+ # There is no safe bearer-token or bootstrap-claim direct-write
169
+ # grammar. Signed packet GET remains available for non-secret work.
170
+ operations.append({"name": spec.name, "version": spec.version,
171
+ "code": op_code, "namespace": namespace,
172
+ "effect": spec.effect, "fields": fields,
173
+ "required": required, "shortest_template": template,
174
+ "example": example, "direct": direct,
175
+ "requires_secure_channel": spec.name in _SECRET_OPERATIONS,
176
+ "direct_write_template": direct_write_template,
177
+ "direct_expected_template": direct_expected_template,
178
+ "direct_write_status": ('rejected_legacy_secret_url'
179
+ if legacy_direct_write else None)})
180
+ for namespace in sorted(namespaces):
181
+ self._add("namespace", namespace, value=namespace)
182
+ self.document = {"version": 1, "entry": entry,
183
+ "codes": {kind: sorted(values.values(), key=lambda row: row["code"])
184
+ for kind, values in self._by_code.items()},
185
+ "operations": operations}
186
+ if published is not None:
187
+ self._preserve_published(published)
188
+ self.etag = '"' + digest(self.document)[7:] + '"'
189
+ self.index_document = {"version": 1, "entry": entry, "namespaces": [
190
+ {"name": namespace, "code": self.code_for("namespace", namespace),
191
+ "operations": [{"name": row["name"], "code": row["code"],
192
+ "effect": row["effect"]}
193
+ for row in operations if row["namespace"] == namespace]}
194
+ for namespace in sorted(namespaces)],
195
+ "retired": [{"kind": kind, "code": row["code"],
196
+ "identity": row["identity"],
197
+ "replaced_by": row.get("replaced_by")}
198
+ for kind in _PREFIX for row in self.document["codes"][kind]
199
+ if row["deprecated"]]}
200
+ self.index_etag = '"' + digest(self.index_document)[7:] + '"'
201
+ schema_operations = []
202
+ for spec in specs:
203
+ schema_operations.append({**registry.describe(spec),
204
+ "input": registry.schema(spec.input_schema),
205
+ "output": registry.schema(spec.output_schema)})
206
+ self.schema_document = {"version": 1, "entry": entry, "operations": schema_operations}
207
+ self.schema_etag = '"' + digest(self.schema_document)[7:] + '"'
208
+
209
+ def _preserve_published(self, published: dict) -> None:
210
+ """Reject changed meanings and retain retired codes as visible tombstones."""
211
+ require(published.get("version") == 1 and published.get("entry") == self.entry,
212
+ "incompatible_published_dictionary")
213
+ old_operations = {row["code"]: row for row in published.get("operations", [])}
214
+ new_operations = {row["code"]: row for row in self.document["operations"]}
215
+ for code, old in old_operations.items():
216
+ if code in new_operations:
217
+ require(canonical(old) == canonical(new_operations[code]),
218
+ "published_operation_changed")
219
+ for kind in _PREFIX:
220
+ current = self._by_code[kind]
221
+ for old in published.get("codes", {}).get(kind, []):
222
+ code = old["code"]
223
+ if code in current:
224
+ require(current[code]["identity"] == old["identity"],
225
+ "published_short_code_reused")
226
+ else:
227
+ retired = {**old, "deprecated": True}
228
+ current[code] = retired
229
+ self.document["codes"][kind].append(retired)
230
+ self.document["codes"][kind].sort(key=lambda row: row["code"])
231
+
232
+ def _add(self, kind: str, identity: str, **data) -> str:
233
+ code = _code(kind, identity)
234
+ existing = self._by_code[kind].get(code)
235
+ require(existing is None or existing["identity"] == identity, "short_code_collision")
236
+ row = {"code": code, "identity": identity, "deprecated": False,
237
+ "replaced_by": None, **data}
238
+ self._by_code[kind][code] = row
239
+ self._by_identity[kind][identity] = code
240
+ return code
241
+
242
+ def resolve(self, kind: str, code: str) -> dict:
243
+ require(kind in _PREFIX, "unknown_short_code_kind")
244
+ try:
245
+ row = self._by_code[kind][code]
246
+ except KeyError as exc:
247
+ raise Failure("unknown_short_code") from exc
248
+ require(not row["deprecated"], "deprecated_short_code",
249
+ details={"replaced_by": row.get("replaced_by")})
250
+ return row
251
+
252
+ def code_for(self, kind: str, identity: str) -> str:
253
+ require(kind in _PREFIX, "unknown_short_code_kind")
254
+ try:
255
+ return self._by_identity[kind][identity]
256
+ except KeyError as exc:
257
+ raise Failure("unknown_short_code") from exc
258
+
259
+ def resolve_operation(self, code: str):
260
+ self.resolve("operation", code)
261
+ return self._operations[code]
262
+
263
+ def lookup_document(self, key: str) -> dict:
264
+ """Return a small namespace or operation dictionary by name or code."""
265
+ require(type(key) is str and bool(key), "unknown_short_code")
266
+ for kind in ("namespace", "operation"):
267
+ row = self._by_code[kind].get(key)
268
+ if row is None and kind == "operation":
269
+ row = self._by_code[kind].get(self._by_identity[kind].get(key))
270
+ if row is not None and row["deprecated"]:
271
+ replacement_code = row.get("replaced_by")
272
+ replacement_row = (self._by_code["operation"].get(replacement_code)
273
+ if replacement_code else None)
274
+ return {"version": 1, "entry": self.entry, "scope": key,
275
+ "kind": kind, "identity": row["identity"],
276
+ "deprecated": True, "replaced_by": replacement_code,
277
+ "replacement": ({"code": replacement_code,
278
+ "identity": replacement_row["identity"]}
279
+ if replacement_row else None)}
280
+ if key.startswith("n") and key in self._by_code["namespace"]:
281
+ namespace = self.resolve("namespace", key)["identity"]
282
+ operations = [row for row in self.document["operations"]
283
+ if row["namespace"] == namespace]
284
+ elif key in self._by_identity["namespace"]:
285
+ namespace = key
286
+ operations = [row for row in self.document["operations"]
287
+ if row["namespace"] == namespace]
288
+ else:
289
+ if key in self._by_code["operation"]:
290
+ code = key
291
+ else:
292
+ identity = key if "@" in key else key + "@1"
293
+ code = self.code_for("operation", identity)
294
+ if "@" not in key and self._by_code["operation"][code]["deprecated"]:
295
+ active = [(spec.version, candidate) for candidate, spec in self._operations.items()
296
+ if spec.name == key and not self._by_code["operation"][candidate]["deprecated"]]
297
+ require(active, "deprecated_short_code")
298
+ code = max(active)[1]
299
+ self.resolve_operation(code)
300
+ operations = [row for row in self.document["operations"] if row["code"] == code]
301
+ namespace = operations[0]["namespace"]
302
+ identities = {f"{row['name']}@{row['version']}" for row in operations}
303
+ codes = {
304
+ "namespace": [row for row in self.document["codes"]["namespace"]
305
+ if row["identity"] == namespace],
306
+ "operation": [row for row in self.document["codes"]["operation"]
307
+ if row["identity"] in identities],
308
+ "field": [row for row in self.document["codes"]["field"]
309
+ if row.get("operation") in identities],
310
+ "enum": [row for row in self.document["codes"]["enum"]
311
+ if row.get("operation") in identities],
312
+ }
313
+ return {"version": 1, "entry": self.entry, "scope": key,
314
+ "codes": codes, "operations": operations}
315
+
316
+ def etag_for(self, key: str) -> str:
317
+ return '"' + digest(self.lookup_document(key))[7:] + '"'
318
+
319
+ def decode_get_path(self, operation_code: str, segments: list[str] | tuple[str, ...]):
320
+ """Decode a read-only path into (OperationSpec, arguments), without authentication."""
321
+ spec = self.resolve_operation(operation_code)
322
+ require(spec.effect == "read", "direct_path_read_only")
323
+ schema = self.registry.schema(spec.input_schema)
324
+ properties = schema.get("properties", {})
325
+ required = list(schema.get("required", []))
326
+ require(all(name in properties and _is_scalar(properties[name]) for name in required),
327
+ "direct_path_unavailable")
328
+ require(len(segments) >= len(required), "missing_path_argument")
329
+ values = {}
330
+ for name, raw in zip(required, segments[:len(required)]):
331
+ values[name] = self._decode_value(spec, name, raw)
332
+ rest = segments[len(required):]
333
+ require(len(rest) % 2 == 0, "invalid_path_arguments")
334
+ for field_code, raw in zip(rest[::2], rest[1::2]):
335
+ field = self.resolve("field", field_code)
336
+ require(field.get("operation") == f"{spec.name}@{spec.version}",
337
+ "field_code_operation_mismatch")
338
+ name = field["path"]
339
+ require(name not in values and name in properties and _is_scalar(properties[name]),
340
+ "invalid_path_argument")
341
+ values[name] = self._decode_value(spec, name, raw)
342
+ self.registry.validate(spec.input_schema, values)
343
+ return spec, values
344
+
345
+ def decode_direct_write_path(
346
+ self, operation_code: str, segments: list[str] | tuple[str, ...],
347
+ ):
348
+ """Legacy bearer/bootstrap URL grammar is intentionally disabled."""
349
+ raise Failure("secure_channel_required")
350
+
351
+ def _decode_value(self, spec, name: str, raw: str):
352
+ require(type(raw) is str, "invalid_path_argument")
353
+ schema = self.registry.schema(spec.input_schema)["properties"][name]
354
+ if "enum" in schema or "const" in schema:
355
+ enum = self.resolve("enum", raw)
356
+ require(enum.get("operation") == f"{spec.name}@{spec.version}" and
357
+ enum.get("path") == name, "enum_code_field_mismatch")
358
+ return enum["value"]
359
+ kind = schema.get("type")
360
+ if kind == "string":
361
+ return raw
362
+ if kind == "boolean":
363
+ require(raw in {"true", "false"}, "invalid_path_boolean")
364
+ return raw == "true"
365
+ if kind == "integer":
366
+ require(raw.isdecimal() and (raw == "0" or not raw.startswith("0")),
367
+ "invalid_path_integer")
368
+ return int(raw)
369
+ if kind == "number":
370
+ try:
371
+ value = float(raw)
372
+ except ValueError as exc:
373
+ raise Failure("invalid_path_number") from exc
374
+ require(value == value and abs(value) != float("inf"), "invalid_path_number")
375
+ return value
376
+ raise Failure("direct_path_unavailable")
377
+
378
+
379
+ def build_dictionary(registry, entry: str = "network", *,
380
+ published: dict | None | object = _DEFAULT_PUBLISHED) -> ShortCodeDictionary:
381
+ if published is _DEFAULT_PUBLISHED:
382
+ try:
383
+ published = loads(files("msg.data").joinpath("shortcodes.json").read_bytes())
384
+ except FileNotFoundError:
385
+ # A source checkout can generate its first publication snapshot.
386
+ published = None
387
+ return ShortCodeDictionary(registry, entry, published=published)
@@ -0,0 +1,75 @@
1
+ """GraphQL is a thin adapter over OperationExecutor, never another business API."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+
6
+ from msg.core.codec import freeze_json, wire
7
+ from msg.core.errors import Failure, require
8
+ from msg.core.executor import result_wire
9
+ from msg.transports.packet import decode_packet
10
+
11
+
12
+ class GraphQLAdapter:
13
+ def __init__(self,service):
14
+ try:
15
+ import graphql
16
+ except ImportError as exc:
17
+ raise Failure('dependency_unavailable',details={'dependency':'graphql-core'}) from exc
18
+ self.graphql=graphql
19
+ self.service=service
20
+ scalar=graphql.GraphQLScalarType('JSON',serialize=wire,parse_value=lambda value:wire(freeze_json(value)),
21
+ parse_literal=lambda node,variables=None:graphql.value_from_ast_untyped(node,variables))
22
+
23
+ def field(effect,name=None):
24
+ async def resolve(root,info,packet):
25
+ try:
26
+ request=decode_packet(packet,service.settings.server.limits.max_request_bytes)
27
+ spec=service.registry.operation(request.operation,request.contract_version)
28
+ require('network' in spec.entries,'entry_not_allowed')
29
+ require((spec.effect=='read')==(effect=='read'),'graphql_effect_mismatch')
30
+ require(name is None or request.operation==name,'operation_mismatch')
31
+ return result_wire(await service.executor.execute(request,entry='network'))
32
+ except Failure as exc:
33
+ raise graphql.GraphQLError(exc.code,extensions={'code':exc.code}) from exc
34
+ return graphql.GraphQLField(scalar,args={'packet':graphql.GraphQLArgument(graphql.GraphQLNonNull(scalar))},resolve=resolve)
35
+ queries={'call':field('read')}
36
+ mutations={'call':field('transaction')}
37
+ for spec in service.registry.operations('network'):
38
+ base_name=spec.name.replace('.','_')
39
+ name=base_name if spec.version==1 else f'{base_name}_v{spec.version}'
40
+ table=queries if spec.effect=='read' else mutations
41
+ require(name not in table,'graphql_name_conflict')
42
+ table[name]=field(spec.effect,spec.name)
43
+ self.schema=graphql.GraphQLSchema(query=graphql.GraphQLObjectType('Query',queries),
44
+ mutation=graphql.GraphQLObjectType('Mutation',mutations))
45
+
46
+ async def handle(self,body, *, operation_kind=None):
47
+ require(isinstance(body,dict) and set(body)<={'query','variables','operationName'},'invalid_graphql_request')
48
+ require(isinstance(body.get('query'),str) and len(body['query'])<=65536,'invalid_graphql_query')
49
+ require(body.get('variables') is None or isinstance(body['variables'],dict),'invalid_graphql_variables')
50
+ require(operation_kind in {None,'query','mutation'},'invalid_graphql_operation')
51
+ g=self.graphql
52
+ try:
53
+ document=g.parse(body['query'],max_tokens=10000)
54
+ operation=g.get_operation_ast(document,body.get('operationName'))
55
+ require(operation is not None,'graphql_operation_required')
56
+ if operation_kind is not None:
57
+ require(operation.operation.value==operation_kind,'graphql_effect_mismatch')
58
+ errors=g.validate(self.schema,document,max_errors=20)
59
+ if errors:
60
+ return {'errors':[{'message':'graphql_validation_error','extensions':{'code':'graphql_validation_error'}}]}
61
+ result=g.execute(self.schema,document,variable_values=body.get('variables'),operation_name=body.get('operationName'))
62
+ if inspect.isawaitable(result):
63
+ result=await result
64
+ except (g.GraphQLError,RecursionError):
65
+ return {'errors':[{'message':'graphql_parse_error','extensions':{'code':'graphql_parse_error'}}]}
66
+ output={}
67
+ if result.data is not None:
68
+ output['data']=result.data
69
+ if result.errors:
70
+ # GraphQL's default variable-coercion errors may contain user inputs.
71
+ # Return stable codes without echoing credentials, payloads, or URLs.
72
+ output['errors']=[{'message':e.extensions.get('code','graphql_execution_error'),
73
+ 'extensions':{'code':e.extensions.get('code','graphql_execution_error')},
74
+ **({'path':e.path} if e.path else {})} for e in result.errors]
75
+ return output
msg/transports/http.py ADDED
@@ -0,0 +1,72 @@
1
+ """HTTP ingress policy and the public adapter interface.
2
+
3
+ Route implementations remain in http_routes. The outer guard only rejects
4
+ passive execution attempts; it cannot normalize a URL or execute an operation.
5
+ All admitted requests still pass the router's URL, Host, proof and authorization
6
+ checks. Keeping this guard outside dispatch preserves denial precedence for
7
+ removed credential slots without weakening the shared secret classifier.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse, Response
13
+
14
+ from msg.core.errors import Failure
15
+ from msg.transports.http_routes import (
16
+ BASE_HEADERS, HOME_HEADERS, HOME_HTML, HOME_LOGO, HOME_FAVICON, HOME_MARKDOWN,
17
+ SUBJECT_RESOURCE_ALIASES, SUBJECT_KEY_ALIASES, SUBJECT_OPERATION_ALIASES,
18
+ SEARCH_V2_SEGMENTS, SEARCH_V3_SEGMENTS, SEARCH_V4_SEGMENTS, GREP_V1_SEGMENTS,
19
+ TRANSFER_OPERATIONS, RouteEffect, RouteSpec, operation_route, classify_route,
20
+ passive_client, passive_client_response, decode_query_path, compile_read_query,
21
+ compile_read_query_v2, decode_read_query_path, decode_search_query_path,
22
+ compile_lexical_search, decode_search_v2_path, search_path_from_args,
23
+ path_read_proof, json_response, error_status, body_bytes, describe_resource,
24
+ parse_view, parse_stable_view, create_app as create_router,
25
+ )
26
+
27
+
28
+ class PassiveGetBoundary:
29
+ """Deny known write GETs before parsing a credential-bearing representation."""
30
+
31
+ def __init__(self, app, *, service):
32
+ self.app = app
33
+ self.service = service
34
+
35
+ async def __call__(self, scope, receive, send):
36
+ executor = getattr(self.service, 'executor', None)
37
+ if scope['type'] == 'http' and executor is not None and executor.recovery_drill_active():
38
+ # Public ACLs in an old snapshot can also have been revoked. Do not
39
+ # serve business content before authority replay and local promotion.
40
+ health = (scope.get('raw_path', b'') == b'/healthz' and
41
+ scope['method'] in {'GET', 'HEAD'} and not scope.get('query_string'))
42
+ data = ({'status':'recovery_quarantined','ready':False,
43
+ 'writes_enabled':False,'outbound_enabled':False}
44
+ if health else {'error':{'code':'recovery_quarantined','retryable':False}})
45
+ response = (Response(status_code=503, headers=BASE_HEADERS)
46
+ if scope['method'] == 'HEAD' else
47
+ JSONResponse(data, status_code=503, headers=BASE_HEADERS))
48
+ await response(scope, receive, send)
49
+ return
50
+ if scope['type'] == 'http' and scope['method'] == 'GET':
51
+ raw = scope.get('raw_path') or scope['path'].encode('utf-8')
52
+ maximum = self.service.settings.server.limits.max_path_bytes
53
+ if raw.startswith(b'/-/g/') and len(raw) <= maximum:
54
+ request = Request(scope)
55
+ if passive_client(request):
56
+ try:
57
+ route = classify_route(raw.decode('ascii'), 'GET', self.service.registry)
58
+ except (Failure, UnicodeDecodeError):
59
+ # Unknown and encoded routes are not interpreted here.
60
+ # Strict raw-target validation still applies downstream.
61
+ pass
62
+ else:
63
+ if route.effect in {RouteEffect.BUSINESS_WRITE, RouteEffect.EXTERNAL_EFFECT}:
64
+ await passive_client_response()(scope, receive, send)
65
+ return
66
+ await self.app(scope, receive, send)
67
+
68
+
69
+ def create_app(service):
70
+ app = create_router(service)
71
+ app.add_middleware(PassiveGetBoundary, service=service)
72
+ return app