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
|
@@ -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
|