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/storage/sqlite.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""One transactional metadata session; handlers never own commit rights."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextvars
|
|
6
|
+
import fcntl
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from msg.core.errors import Failure, require
|
|
15
|
+
from msg.core.query import QueryResult, SqlParameters
|
|
16
|
+
from msg.storage.query import SessionQueryResult
|
|
17
|
+
from msg.storage.session import RelationalSession
|
|
18
|
+
|
|
19
|
+
_SCHEMA = """
|
|
20
|
+
PRAGMA foreign_keys=ON;
|
|
21
|
+
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
|
22
|
+
INSERT OR IGNORE INTO schema_version VALUES (1);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS resources (
|
|
24
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, name TEXT NOT NULL,
|
|
25
|
+
parent TEXT REFERENCES resources(id) DEFERRABLE INITIALLY DEFERRED,
|
|
26
|
+
owner TEXT NOT NULL, grp TEXT NOT NULL, mode INTEGER NOT NULL,
|
|
27
|
+
generation INTEGER NOT NULL, revision TEXT, state TEXT NOT NULL,
|
|
28
|
+
created_at TEXT NOT NULL, modified_at TEXT NOT NULL, body TEXT NOT NULL,
|
|
29
|
+
UNIQUE(parent,name));
|
|
30
|
+
CREATE UNIQUE INDEX IF NOT EXISTS one_root ON resources((1)) WHERE parent IS NULL;
|
|
31
|
+
CREATE INDEX IF NOT EXISTS resources_parent ON resources(parent,id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS resources_time ON resources(created_at,id);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS resources_owner ON resources(owner,id);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS resources_type_state ON resources(type,state,id);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS resource_tags (
|
|
36
|
+
resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
37
|
+
tag TEXT NOT NULL, PRIMARY KEY(tag,resource_id));
|
|
38
|
+
CREATE INDEX IF NOT EXISTS resource_tags_resource ON resource_tags(resource_id);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
40
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id),
|
|
41
|
+
created_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS revisions_resource ON revisions(resource_id,created_at,id);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
44
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
|
45
|
+
source_id TEXT NOT NULL, type TEXT NOT NULL, target_id TEXT NOT NULL,
|
|
46
|
+
target_revision TEXT, body TEXT NOT NULL);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS relations_target ON relations(target_id,type);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS identities (id TEXT PRIMARY KEY, kind TEXT NOT NULL, generation INTEGER NOT NULL, body TEXT NOT NULL);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS memberships (org TEXT, subject TEXT, generation INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(org,subject));
|
|
50
|
+
CREATE TABLE IF NOT EXISTS emails (subject TEXT PRIMARY KEY, generation INTEGER NOT NULL, body TEXT NOT NULL);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY, subject TEXT NOT NULL, body TEXT NOT NULL);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS credentials_subject ON credentials(subject);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS token_deliveries (
|
|
54
|
+
credential_id TEXT PRIMARY KEY, subject TEXT NOT NULL, request_id TEXT NOT NULL,
|
|
55
|
+
request_digest TEXT NOT NULL, recovery_verifier TEXT NOT NULL,
|
|
56
|
+
recovery_expires_at TEXT NOT NULL, claimed_at TEXT, consumed_at TEXT);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS token_deliveries_subject ON token_deliveries(subject,request_id);
|
|
58
|
+
CREATE TABLE IF NOT EXISTS identity_keys (
|
|
59
|
+
key_id TEXT PRIMARY KEY, subject TEXT NOT NULL, public_key TEXT NOT NULL,
|
|
60
|
+
created_at TEXT NOT NULL, retired_at TEXT, is_primary INTEGER NOT NULL DEFAULT 0);
|
|
61
|
+
CREATE UNIQUE INDEX IF NOT EXISTS identity_keys_primary ON identity_keys(subject) WHERE is_primary=1;
|
|
62
|
+
CREATE INDEX IF NOT EXISTS identity_keys_subject ON identity_keys(subject,created_at,key_id);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS encryption_subkeys (
|
|
64
|
+
key_id TEXT PRIMARY KEY, subject TEXT NOT NULL, recipient TEXT NOT NULL UNIQUE,
|
|
65
|
+
public_key TEXT NOT NULL, created_at TEXT NOT NULL, retired_at TEXT,
|
|
66
|
+
is_primary INTEGER NOT NULL DEFAULT 0);
|
|
67
|
+
CREATE UNIQUE INDEX IF NOT EXISTS encryption_subkeys_primary ON encryption_subkeys(subject) WHERE is_primary=1;
|
|
68
|
+
CREATE INDEX IF NOT EXISTS encryption_subkeys_subject ON encryption_subkeys(subject,created_at,key_id);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS certificates (id TEXT PRIMARY KEY, subject TEXT NOT NULL, parent TEXT, revoked INTEGER NOT NULL DEFAULT 0, body TEXT NOT NULL);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS csrs (id TEXT PRIMARY KEY, generation INTEGER NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL, state_body TEXT NOT NULL);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS results (subject TEXT, request_id TEXT, digest TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(subject,request_id));
|
|
72
|
+
CREATE TABLE IF NOT EXISTS batches (subject TEXT, request_id TEXT, digest TEXT NOT NULL, PRIMARY KEY(subject,request_id));
|
|
73
|
+
CREATE TABLE IF NOT EXISTS transfers (id TEXT PRIMARY KEY, subject TEXT NOT NULL, generation INTEGER NOT NULL, body TEXT NOT NULL, limits TEXT NOT NULL DEFAULT '{}');
|
|
74
|
+
CREATE TABLE IF NOT EXISTS chunks (transfer_id TEXT NOT NULL, offset INTEGER NOT NULL, length INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(transfer_id,offset));
|
|
75
|
+
CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL, body TEXT NOT NULL);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS topic_event_projection (
|
|
77
|
+
seq INTEGER PRIMARY KEY REFERENCES events(seq) ON DELETE CASCADE, topic TEXT NOT NULL);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS topic_event_projection_topic ON topic_event_projection(topic,seq DESC);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, digest TEXT UNIQUE NOT NULL, previous TEXT, body TEXT NOT NULL);
|
|
80
|
+
CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit BEGIN SELECT RAISE(ABORT,'append_only_audit'); END;
|
|
81
|
+
CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit BEGIN SELECT RAISE(ABORT,'append_only_audit'); END;
|
|
82
|
+
CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, dedupe TEXT UNIQUE NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL, next_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS watches (subject TEXT, resource TEXT, PRIMARY KEY(subject,resource));
|
|
84
|
+
CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, sender TEXT, recipient TEXT, resource TEXT, event_id TEXT, body TEXT NOT NULL);
|
|
85
|
+
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
|
86
|
+
subject TEXT PRIMARY KEY, url TEXT NOT NULL, nonce TEXT NOT NULL, ciphertext TEXT NOT NULL,
|
|
87
|
+
enabled INTEGER NOT NULL CHECK(enabled IN (0,1)), generation INTEGER NOT NULL);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS reactions (subject TEXT, resource TEXT, kind TEXT, revision TEXT NOT NULL DEFAULT '', body TEXT NOT NULL, PRIMARY KEY(subject,resource,kind,revision));
|
|
89
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS projections (resource_id TEXT PRIMARY KEY, text TEXT NOT NULL);
|
|
91
|
+
CREATE TABLE IF NOT EXISTS email_challenges (subject TEXT PRIMARY KEY, digest TEXT NOT NULL, expires TEXT NOT NULL);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS achievement_ceremonies (
|
|
93
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS achievement_ceremonies_subject ON achievement_ceremonies(subject);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS achievement_grants (
|
|
96
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, achievement_id TEXT NOT NULL,
|
|
97
|
+
spec_version INTEGER NOT NULL, body TEXT NOT NULL,
|
|
98
|
+
UNIQUE(subject,achievement_id,spec_version));
|
|
99
|
+
CREATE INDEX IF NOT EXISTS achievement_grants_lookup ON achievement_grants(achievement_id,subject);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS order_escrow_decisions (
|
|
101
|
+
order_id TEXT PRIMARY KEY REFERENCES store_orders(id),
|
|
102
|
+
id TEXT NOT NULL UNIQUE, body TEXT NOT NULL, signature TEXT NOT NULL,
|
|
103
|
+
source_proof TEXT NOT NULL,
|
|
104
|
+
transaction_id TEXT NOT NULL UNIQUE REFERENCES money_ledger(id));
|
|
105
|
+
CREATE TRIGGER IF NOT EXISTS escrow_decision_no_update BEFORE UPDATE ON order_escrow_decisions
|
|
106
|
+
BEGIN SELECT RAISE(ABORT,'append_only_escrow_decision'); END;
|
|
107
|
+
CREATE TRIGGER IF NOT EXISTS escrow_decision_no_delete BEFORE DELETE ON order_escrow_decisions
|
|
108
|
+
BEGIN SELECT RAISE(ABORT,'append_only_escrow_decision'); END;
|
|
109
|
+
CREATE TABLE IF NOT EXISTS achievement_pins (
|
|
110
|
+
subject TEXT NOT NULL REFERENCES identities(id),
|
|
111
|
+
grant_id TEXT NOT NULL REFERENCES achievement_grants(id),
|
|
112
|
+
position INTEGER NOT NULL CHECK(position>=0),
|
|
113
|
+
PRIMARY KEY(subject,grant_id), UNIQUE(subject,position));
|
|
114
|
+
CREATE TABLE IF NOT EXISTS share_grants (
|
|
115
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
116
|
+
grantor TEXT NOT NULL, grantee TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
117
|
+
expires_at TEXT NOT NULL, revoked_at TEXT);
|
|
118
|
+
CREATE UNIQUE INDEX IF NOT EXISTS share_grants_active ON share_grants(resource_id,grantee)
|
|
119
|
+
WHERE revoked_at IS NULL;
|
|
120
|
+
CREATE INDEX IF NOT EXISTS share_grants_grantee ON share_grants(grantee,resource_id);
|
|
121
|
+
CREATE TABLE IF NOT EXISTS share_grants_v2 (
|
|
122
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
123
|
+
grantor TEXT NOT NULL, grantee TEXT NOT NULL, grantee_kind TEXT NOT NULL
|
|
124
|
+
CHECK(grantee_kind IN ('user','group')), parent_id TEXT REFERENCES share_grants_v2(id),
|
|
125
|
+
operations TEXT NOT NULL, constraints TEXT NOT NULL, allow_reshare INTEGER NOT NULL
|
|
126
|
+
CHECK(allow_reshare IN (0,1)), created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
|
|
127
|
+
revoked_at TEXT);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS share_grants_v2_resource ON share_grants_v2(resource_id,grantee,grantee_kind);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS share_grants_v2_parent ON share_grants_v2(parent_id);
|
|
130
|
+
CREATE TABLE IF NOT EXISTS share_links (
|
|
131
|
+
id TEXT PRIMARY KEY, resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
|
132
|
+
grantor TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
133
|
+
verifier TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
|
|
134
|
+
expires_at TEXT NOT NULL, revoked_at TEXT);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS share_links_resource ON share_links(resource_id,grantor);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS dm_conversations (
|
|
137
|
+
pair TEXT PRIMARY KEY, resource_id TEXT NOT NULL UNIQUE REFERENCES resources(id),
|
|
138
|
+
participant_a TEXT NOT NULL, participant_b TEXT NOT NULL, initiator TEXT NOT NULL,
|
|
139
|
+
conversation_kind TEXT NOT NULL DEFAULT 'direct' CHECK(conversation_kind='direct'),
|
|
140
|
+
state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
141
|
+
CHECK(participant_a < participant_b), CHECK(state IN ('pending','active','rejected')));
|
|
142
|
+
CREATE INDEX IF NOT EXISTS dm_conversations_participants ON dm_conversations(participant_a,participant_b);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS dm_blocks (
|
|
144
|
+
blocker TEXT NOT NULL, blocked TEXT NOT NULL, PRIMARY KEY(blocker,blocked));
|
|
145
|
+
CREATE TABLE IF NOT EXISTS dm_archives (
|
|
146
|
+
subject TEXT NOT NULL, pair TEXT NOT NULL REFERENCES dm_conversations(pair),
|
|
147
|
+
PRIMARY KEY(subject,pair));
|
|
148
|
+
CREATE TABLE IF NOT EXISTS presence (
|
|
149
|
+
subject TEXT PRIMARY KEY, expires_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
150
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
151
|
+
id TEXT PRIMARY KEY REFERENCES resources(id), subject TEXT NOT NULL,
|
|
152
|
+
issued_at TEXT NOT NULL, expires_at TEXT, body TEXT NOT NULL);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS claims_subject ON claims(subject,issued_at,id);
|
|
154
|
+
CREATE TABLE IF NOT EXISTS topic_settings (
|
|
155
|
+
topic TEXT PRIMARY KEY REFERENCES resources(id), membership_policy TEXT NOT NULL,
|
|
156
|
+
CHECK(membership_policy IN ('open','approval','invite','closed')));
|
|
157
|
+
CREATE TABLE IF NOT EXISTS topic_memberships (
|
|
158
|
+
topic TEXT NOT NULL REFERENCES resources(id), subject TEXT NOT NULL,
|
|
159
|
+
role TEXT NOT NULL CHECK(role IN ('admin','member')),
|
|
160
|
+
status TEXT NOT NULL CHECK(status IN ('active','pending','invited','left','removed')),
|
|
161
|
+
joined_at TEXT, invited_by TEXT, PRIMARY KEY(topic,subject));
|
|
162
|
+
CREATE INDEX IF NOT EXISTS topic_memberships_subject ON topic_memberships(subject,topic);
|
|
163
|
+
CREATE TABLE IF NOT EXISTS topic_bans (
|
|
164
|
+
topic TEXT NOT NULL REFERENCES resources(id), subject TEXT NOT NULL,
|
|
165
|
+
actor TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT, reason TEXT,
|
|
166
|
+
status TEXT NOT NULL CHECK(status IN ('active','lifted')),
|
|
167
|
+
PRIMARY KEY(topic,subject));
|
|
168
|
+
CREATE TABLE IF NOT EXISTS system_sources (
|
|
169
|
+
resource_id TEXT PRIMARY KEY REFERENCES resources(id), source_path TEXT NOT NULL UNIQUE,
|
|
170
|
+
rule_id TEXT NOT NULL UNIQUE, source_kind TEXT NOT NULL,
|
|
171
|
+
source_version INTEGER NOT NULL, source_digest TEXT NOT NULL,
|
|
172
|
+
revision_id TEXT NOT NULL REFERENCES revisions(id));
|
|
173
|
+
CREATE TABLE IF NOT EXISTS personal_revision_proofs (
|
|
174
|
+
revision_id TEXT PRIMARY KEY REFERENCES revisions(id), subject TEXT NOT NULL,
|
|
175
|
+
kind TEXT NOT NULL, signature TEXT NOT NULL, signed_envelope TEXT NOT NULL,
|
|
176
|
+
created_at TEXT NOT NULL);
|
|
177
|
+
CREATE TABLE IF NOT EXISTS recovery_policies (
|
|
178
|
+
subject TEXT NOT NULL, version INTEGER NOT NULL, encryption_key_id TEXT NOT NULL,
|
|
179
|
+
created_at TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY(subject,version));
|
|
180
|
+
CREATE TABLE IF NOT EXISTS recovery_envelopes (
|
|
181
|
+
id TEXT PRIMARY KEY, owner TEXT NOT NULL, ciphertext_resource TEXT NOT NULL,
|
|
182
|
+
ciphertext_revision TEXT NOT NULL, policy_version INTEGER NOT NULL,
|
|
183
|
+
encryption_key_id TEXT NOT NULL, purpose TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
184
|
+
body TEXT NOT NULL,
|
|
185
|
+
UNIQUE(owner,ciphertext_resource,ciphertext_revision,purpose));
|
|
186
|
+
CREATE INDEX IF NOT EXISTS recovery_envelopes_owner ON recovery_envelopes(owner,created_at,id);
|
|
187
|
+
CREATE TABLE IF NOT EXISTS custodial_vault (
|
|
188
|
+
subject TEXT PRIMARY KEY, signing_key_id TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
|
|
189
|
+
signing_nonce TEXT, signing_ciphertext TEXT, age_nonce TEXT, age_ciphertext TEXT,
|
|
190
|
+
status TEXT NOT NULL,
|
|
191
|
+
created_at TEXT NOT NULL, destroyed_at TEXT,
|
|
192
|
+
CONSTRAINT custodial_vault_lifecycle_check CHECK (
|
|
193
|
+
status IN ('active','decrypt_only','destroyed') AND
|
|
194
|
+
(status <> 'decrypt_only' OR (signing_nonce IS NULL AND signing_ciphertext IS NULL
|
|
195
|
+
AND age_nonce IS NOT NULL AND age_ciphertext IS NOT NULL))));
|
|
196
|
+
CREATE TABLE IF NOT EXISTS custodial_upgrades (
|
|
197
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
198
|
+
status TEXT NOT NULL CHECK(status IN ('pending','pending_rewrap','failed','completed')),
|
|
199
|
+
expires_at TEXT NOT NULL, challenge TEXT NOT NULL,
|
|
200
|
+
ephemeral_nonce TEXT, ephemeral_ciphertext TEXT, body TEXT NOT NULL);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS custodial_upgrades_subject ON custodial_upgrades(subject,status,expires_at);
|
|
202
|
+
CREATE TABLE IF NOT EXISTS legacy_directives (
|
|
203
|
+
subject TEXT PRIMARY KEY, resource_id TEXT NOT NULL UNIQUE REFERENCES resources(id),
|
|
204
|
+
created_at TEXT NOT NULL);
|
|
205
|
+
CREATE TABLE IF NOT EXISTS legacy_directive_versions (
|
|
206
|
+
revision_id TEXT PRIMARY KEY REFERENCES revisions(id), resource_id TEXT NOT NULL,
|
|
207
|
+
subject TEXT NOT NULL, body TEXT NOT NULL);
|
|
208
|
+
CREATE TABLE IF NOT EXISTS legacy_states (
|
|
209
|
+
subject TEXT PRIMARY KEY, state TEXT NOT NULL
|
|
210
|
+
CHECK(state IN ('active','unreachable','recovery_requested','legacy')));
|
|
211
|
+
CREATE TABLE IF NOT EXISTS sync_checkpoints (
|
|
212
|
+
id TEXT PRIMARY KEY, subject TEXT NOT NULL, credential_id TEXT NOT NULL,
|
|
213
|
+
version INTEGER NOT NULL, expires_at TEXT NOT NULL, body TEXT NOT NULL);
|
|
214
|
+
CREATE INDEX IF NOT EXISTS sync_checkpoints_subject ON sync_checkpoints(subject,expires_at);
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SqliteSession(RelationalSession):
|
|
219
|
+
def __init__(self, connection, *, write: bool):
|
|
220
|
+
super().__init__(write=write)
|
|
221
|
+
self._connection = connection
|
|
222
|
+
|
|
223
|
+
def execute(self, sql: str, parameters: SqlParameters = (), *,
|
|
224
|
+
write: bool = False) -> QueryResult:
|
|
225
|
+
self.check(write)
|
|
226
|
+
try:
|
|
227
|
+
return SessionQueryResult(self._connection.execute(sql, parameters), self.check)
|
|
228
|
+
except sqlite3.IntegrityError as exc:
|
|
229
|
+
raise Failure("constraint_conflict") from exc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class SqliteMetadataStore:
|
|
233
|
+
def __init__(self,path: Path, *, busy_timeout: float=10):
|
|
234
|
+
self.path=Path(path)
|
|
235
|
+
self.path.parent.mkdir(parents=True,exist_ok=True)
|
|
236
|
+
self.busy_timeout=busy_timeout
|
|
237
|
+
self._current=contextvars.ContextVar('msg_transaction_'+uuid.uuid4().hex,default=None)
|
|
238
|
+
conn=self._connect()
|
|
239
|
+
try:
|
|
240
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
241
|
+
conn.executescript(_SCHEMA)
|
|
242
|
+
from msg.storage.topic_event_migration import migrate_topic_events
|
|
243
|
+
conn.execute('BEGIN IMMEDIATE')
|
|
244
|
+
try:
|
|
245
|
+
migrate_topic_events(conn)
|
|
246
|
+
conn.execute('COMMIT')
|
|
247
|
+
except BaseException:
|
|
248
|
+
conn.execute('ROLLBACK')
|
|
249
|
+
raise
|
|
250
|
+
finally:
|
|
251
|
+
conn.close()
|
|
252
|
+
|
|
253
|
+
def _connect(self):
|
|
254
|
+
conn=sqlite3.connect(self.path,timeout=self.busy_timeout,isolation_level=None,check_same_thread=False)
|
|
255
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
256
|
+
conn.execute("PRAGMA synchronous=FULL")
|
|
257
|
+
return conn
|
|
258
|
+
|
|
259
|
+
async def _acquire_write_fence(self):
|
|
260
|
+
# SQLite can release its own lock on an implicit transaction abort.
|
|
261
|
+
# Keep independent processes out until external compensation completes.
|
|
262
|
+
fd=os.open(self.path.with_name(self.path.name+'.writer.lock'),
|
|
263
|
+
os.O_CREAT|os.O_RDWR|os.O_CLOEXEC|os.O_NOFOLLOW,0o600)
|
|
264
|
+
loop=asyncio.get_running_loop()
|
|
265
|
+
deadline=loop.time()+self.busy_timeout
|
|
266
|
+
try:
|
|
267
|
+
while True:
|
|
268
|
+
try:
|
|
269
|
+
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
270
|
+
return fd
|
|
271
|
+
except BlockingIOError:
|
|
272
|
+
require(loop.time()<deadline,'server_busy',retryable=True)
|
|
273
|
+
await asyncio.sleep(min(0.01,max(0,deadline-loop.time())))
|
|
274
|
+
except BaseException:
|
|
275
|
+
os.close(fd)
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
@asynccontextmanager
|
|
279
|
+
async def transaction(self, *, write):
|
|
280
|
+
existing=self._current.get()
|
|
281
|
+
if existing is not None:
|
|
282
|
+
existing.check(write)
|
|
283
|
+
name='nested_'+uuid.uuid4().hex
|
|
284
|
+
existing.execute('SAVEPOINT '+name)
|
|
285
|
+
rollback_at=len(existing.rollback_effects)
|
|
286
|
+
try:
|
|
287
|
+
yield existing
|
|
288
|
+
existing.execute('RELEASE SAVEPOINT '+name)
|
|
289
|
+
except BaseException as exc:
|
|
290
|
+
existing.execute('ROLLBACK TO SAVEPOINT '+name)
|
|
291
|
+
existing.execute('RELEASE SAVEPOINT '+name)
|
|
292
|
+
await existing.run_rollback_effects(exc,rollback_at)
|
|
293
|
+
raise
|
|
294
|
+
return
|
|
295
|
+
conn=self._connect()
|
|
296
|
+
tx=SqliteSession(conn,write=write)
|
|
297
|
+
token=None
|
|
298
|
+
writer_fence=None
|
|
299
|
+
try:
|
|
300
|
+
if write:
|
|
301
|
+
writer_fence=await self._acquire_write_fence()
|
|
302
|
+
if not write:
|
|
303
|
+
conn.execute("PRAGMA query_only=ON")
|
|
304
|
+
try:
|
|
305
|
+
beginning=asyncio.create_task(asyncio.to_thread(conn.execute,"BEGIN IMMEDIATE" if write else "BEGIN"))
|
|
306
|
+
try:
|
|
307
|
+
await asyncio.shield(beginning)
|
|
308
|
+
except asyncio.CancelledError:
|
|
309
|
+
# A thread cannot be cancelled halfway through SQLite BEGIN.
|
|
310
|
+
# Wait for it before closing/rolling back the connection.
|
|
311
|
+
try:
|
|
312
|
+
await beginning
|
|
313
|
+
finally:
|
|
314
|
+
raise
|
|
315
|
+
except sqlite3.OperationalError as exc:
|
|
316
|
+
raise Failure("server_busy",retryable=True) from exc
|
|
317
|
+
token=self._current.set(tx)
|
|
318
|
+
yield tx
|
|
319
|
+
conn.execute("COMMIT")
|
|
320
|
+
except BaseException as exc:
|
|
321
|
+
if conn.in_transaction:
|
|
322
|
+
conn.execute("ROLLBACK")
|
|
323
|
+
await tx.run_rollback_effects(exc)
|
|
324
|
+
raise
|
|
325
|
+
finally:
|
|
326
|
+
tx.closed=True
|
|
327
|
+
if token is not None:
|
|
328
|
+
self._current.reset(token)
|
|
329
|
+
try:
|
|
330
|
+
conn.close()
|
|
331
|
+
finally:
|
|
332
|
+
if writer_fence is not None:
|
|
333
|
+
os.close(writer_fence)
|
|
334
|
+
|
|
335
|
+
async def close(self):
|
|
336
|
+
# Connections are scoped to transactions, not retained per account.
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def backup(self,destination: Path):
|
|
340
|
+
source=self._connect()
|
|
341
|
+
target=sqlite3.connect(destination)
|
|
342
|
+
try:
|
|
343
|
+
source.backup(target)
|
|
344
|
+
finally:
|
|
345
|
+
target.close()
|
|
346
|
+
source.close()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class FakeMetadataStore(SqliteMetadataStore):
|
|
350
|
+
"""Disposable transactional fake backed by a real isolated SQLite database.
|
|
351
|
+
|
|
352
|
+
Uses the same isolation/rollback behavior rather than unrelated dictionaries.
|
|
353
|
+
It deliberately is not an independent SQL correctness oracle.
|
|
354
|
+
"""
|
|
355
|
+
def __init__(self,path=None,**kwargs):
|
|
356
|
+
self._temporary=tempfile.TemporaryDirectory(prefix='msg-fake-')
|
|
357
|
+
super().__init__(Path(self._temporary.name)/'metadata.db',**kwargs)
|
|
358
|
+
|
|
359
|
+
async def close(self):
|
|
360
|
+
self._temporary.cleanup()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Backfill a rebuildable topic index once, without duplicating signed bodies."""
|
|
2
|
+
|
|
3
|
+
MIGRATION_KEY = 'migration:topic_event_projection:v1'
|
|
4
|
+
EVENT_TYPES = (
|
|
5
|
+
'topic.create', 'topic.member.join', 'topic.member.request', 'topic.member.leave',
|
|
6
|
+
'topic.member.invite', 'topic.member.approve', 'topic.member.remove',
|
|
7
|
+
'topic.member.promote', 'topic.member.demote', 'topic.member.ban', 'topic.member.unban',
|
|
8
|
+
'topic.policy.change', 'topic.archive', 'topic.restore', 'topic.move',
|
|
9
|
+
'topic.chmod', 'topic.chgrp', 'topic.chown', 'topic.configure',
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def migrate_topic_events(connection, *, postgres=False):
|
|
14
|
+
"""Caller holds the schema writer lock; commit index and marker together."""
|
|
15
|
+
parameter = '%s' if postgres else '?'
|
|
16
|
+
if connection.execute(f'SELECT 1 FROM settings WHERE key={parameter}',
|
|
17
|
+
(MIGRATION_KEY,)).fetchone():
|
|
18
|
+
return
|
|
19
|
+
topic = "body::jsonb->'data'->>'topic_id'" if postgres else "json_extract(body,'$.data.topic_id')"
|
|
20
|
+
kind = "body::jsonb->>'type'" if postgres else "json_extract(body,'$.type')"
|
|
21
|
+
placeholders = ','.join([parameter] * len(EVENT_TYPES))
|
|
22
|
+
connection.execute(f'INSERT INTO topic_event_projection (seq,topic) '
|
|
23
|
+
f'SELECT seq,{topic} FROM events WHERE {kind} IN ({placeholders}) '
|
|
24
|
+
f'AND {topic} IS NOT NULL ON CONFLICT DO NOTHING', EVENT_TYPES)
|
|
25
|
+
connection.execute(f'INSERT INTO settings (key,value) VALUES ({parameter},{parameter})',
|
|
26
|
+
(MIGRATION_KEY, 'true'))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Ephemeral wake-up hints for durable PostgreSQL effect jobs.
|
|
2
|
+
|
|
3
|
+
The database is the source of truth. A missed Pub/Sub message must be handled
|
|
4
|
+
by the worker's normal database polling, never by replaying Valkey messages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
import valkey
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValkeyOutboxSignal:
|
|
17
|
+
def __init__(self, url: str, *, channel: str = "msgd:effects") -> None:
|
|
18
|
+
self.client = valkey.Valkey.from_url(url, socket_timeout=1, socket_connect_timeout=1)
|
|
19
|
+
self.channel = channel
|
|
20
|
+
|
|
21
|
+
async def publish_pending(self, effect_ids: Iterable[str]) -> None:
|
|
22
|
+
"""Publish job IDs after their PostgreSQL transaction has committed."""
|
|
23
|
+
ids = tuple(effect_ids)
|
|
24
|
+
if ids:
|
|
25
|
+
await asyncio.to_thread(self._publish_pending, ids)
|
|
26
|
+
|
|
27
|
+
def _publish_pending(self, effect_ids: tuple[str, ...]) -> None:
|
|
28
|
+
for effect_id in effect_ids:
|
|
29
|
+
self.client.publish(self.channel, effect_id)
|
|
30
|
+
|
|
31
|
+
async def wait_for_pending(self, timeout: float = 1.0) -> bool:
|
|
32
|
+
"""Wait for a hint; false means the caller should poll PostgreSQL."""
|
|
33
|
+
return await asyncio.to_thread(self._wait_for_pending, timeout)
|
|
34
|
+
|
|
35
|
+
def _wait_for_pending(self, timeout: float) -> bool:
|
|
36
|
+
deadline = time.monotonic() + timeout
|
|
37
|
+
with self.client.pubsub(ignore_subscribe_messages=True) as subscriber:
|
|
38
|
+
subscriber.subscribe(self.channel)
|
|
39
|
+
while remaining := deadline - time.monotonic():
|
|
40
|
+
if remaining <= 0:
|
|
41
|
+
break
|
|
42
|
+
message = subscriber.get_message(timeout=remaining)
|
|
43
|
+
if message and message.get("type") == "message":
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
File without changes
|
msg/transports/client.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Client-side envelopes; transport selection never changes business semantics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import gzip
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from msg.core.codec import canonical, b64, loads, wire, decode
|
|
9
|
+
from msg.core.errors import Failure, require
|
|
10
|
+
from msg.core.models import TransportLimits
|
|
11
|
+
from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
|
|
12
|
+
from msg.transports.packet import decode_result, require_url_safe_packet, safe_error_code
|
|
13
|
+
from msg.transports.url_safety import require_safe_relative_url
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPTransport:
|
|
17
|
+
name = 'http'
|
|
18
|
+
|
|
19
|
+
def __init__(self, server, *, http=None, max_response_bytes=1048576, max_path_bytes=8192):
|
|
20
|
+
require(isinstance(server, str) and
|
|
21
|
+
all(ord(char) > 32 and ord(char) != 127 for char in server) and
|
|
22
|
+
'\\' not in server, 'invalid_server_url')
|
|
23
|
+
server = server.rstrip('/')
|
|
24
|
+
try:
|
|
25
|
+
parsed = urlsplit(server)
|
|
26
|
+
valid = (parsed.scheme in {'https', 'http'} and parsed.hostname and
|
|
27
|
+
parsed.username is None and parsed.password is None and
|
|
28
|
+
not parsed.query and not parsed.fragment and parsed.path in {'', '/'} and
|
|
29
|
+
(parsed.port is None or parsed.port > 0))
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise Failure('invalid_server_url') from None
|
|
32
|
+
require(valid, 'invalid_server_url')
|
|
33
|
+
self.server = server
|
|
34
|
+
self.http = http or httpx.AsyncClient(base_url=server, timeout=30, follow_redirects=False)
|
|
35
|
+
self._owns_http = http is None
|
|
36
|
+
self.max_response_bytes = max_response_bytes
|
|
37
|
+
self.max_path_bytes = max_path_bytes
|
|
38
|
+
self._description = None
|
|
39
|
+
self.calls = 0
|
|
40
|
+
self.bytes_received = 0
|
|
41
|
+
self.bytes_sent = 0
|
|
42
|
+
|
|
43
|
+
async def _json(self, method, path, *, body=None, headers=None, maximum=None):
|
|
44
|
+
require_safe_relative_url(path, maximum=self.max_path_bytes)
|
|
45
|
+
limit = maximum or self.max_response_bytes
|
|
46
|
+
raw = canonical(body) if body is not None else None
|
|
47
|
+
headers = dict(headers or {})
|
|
48
|
+
if raw is not None:
|
|
49
|
+
headers.setdefault('Content-Type','application/json')
|
|
50
|
+
self.calls += 1
|
|
51
|
+
self.bytes_sent += len(raw or b'') + len(path.encode())
|
|
52
|
+
# Never follow a credential-bearing operation to another origin.
|
|
53
|
+
async with self.http.stream(method, self.server + path, content=raw, headers=headers,
|
|
54
|
+
follow_redirects=False) as response:
|
|
55
|
+
require(not response.is_redirect, 'redirect_not_allowed')
|
|
56
|
+
data = bytearray()
|
|
57
|
+
async for piece in response.aiter_bytes():
|
|
58
|
+
require(len(data)+len(piece)<=limit, 'response_too_large')
|
|
59
|
+
data.extend(piece)
|
|
60
|
+
self.bytes_received += len(data)
|
|
61
|
+
if response.status_code==202 and not data:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
value = loads(bytes(data))
|
|
65
|
+
except Failure:
|
|
66
|
+
raise Failure('invalid_server_response') from None
|
|
67
|
+
require(isinstance(value, dict), 'invalid_server_response')
|
|
68
|
+
if response.status_code>=400 and 'status' not in value:
|
|
69
|
+
error = value.get('error')
|
|
70
|
+
code = error.get('code') if isinstance(error, dict) else None
|
|
71
|
+
raise Failure(safe_error_code(code, 'transport_error'),
|
|
72
|
+
retryable=response.status_code in {502,503,504})
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
async def description(self):
|
|
76
|
+
if self._description is None:
|
|
77
|
+
value = await self._json('GET','/_transports')
|
|
78
|
+
require(value.get('version')==1 and value.get('target_service')==self.server, 'service_mismatch')
|
|
79
|
+
require(isinstance(value.get('operations'),dict), 'invalid_transport_description')
|
|
80
|
+
self._description = value
|
|
81
|
+
return self._description
|
|
82
|
+
|
|
83
|
+
async def discover(self):
|
|
84
|
+
value = await self.description()
|
|
85
|
+
limits = dict(value['limits'])
|
|
86
|
+
limits['max_response_bytes'] = min(limits['max_response_bytes'],self.max_response_bytes)
|
|
87
|
+
limits['max_path_bytes'] = min(limits['max_path_bytes'],self.max_path_bytes)
|
|
88
|
+
return decode(TransportLimits,limits)
|
|
89
|
+
|
|
90
|
+
async def _effect(self, name):
|
|
91
|
+
description = await self.description()
|
|
92
|
+
effect = description['operations'].get(name)
|
|
93
|
+
require(effect in {'read','transaction','external'}, 'unknown_operation')
|
|
94
|
+
return effect
|
|
95
|
+
|
|
96
|
+
def _secure_delivery(self, request):
|
|
97
|
+
if request.operation in SECRET_DELIVERY_MIN_VERSION:
|
|
98
|
+
parsed = urlsplit(self.server)
|
|
99
|
+
require(parsed.scheme == 'https' or parsed.hostname in {
|
|
100
|
+
'testserver', 'localhost', '127.0.0.1', '::1'
|
|
101
|
+
}, 'secure_channel_required')
|
|
102
|
+
|
|
103
|
+
async def call(self, request):
|
|
104
|
+
self._secure_delivery(request)
|
|
105
|
+
await self._effect(request.operation)
|
|
106
|
+
path = '/-/p/' + request.operation
|
|
107
|
+
# Signed reads use POST to a query endpoint: read/write is the operation's
|
|
108
|
+
# declared effect, not inferred from whether HTTP has a request body.
|
|
109
|
+
value = await self._json('POST',path,body=wire(request))
|
|
110
|
+
return decode_result(value)
|
|
111
|
+
|
|
112
|
+
async def close(self):
|
|
113
|
+
if self._owns_http:
|
|
114
|
+
await self.http.aclose()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PathGETTransport(HTTPTransport):
|
|
118
|
+
name = 'path_get'
|
|
119
|
+
|
|
120
|
+
async def call(self, request):
|
|
121
|
+
require_url_safe_packet(request)
|
|
122
|
+
await self._effect(request.operation)
|
|
123
|
+
raw = canonical(request)
|
|
124
|
+
encoded, encoding = b64(raw), 'j'
|
|
125
|
+
compressed = b64(gzip.compress(raw,mtime=0))
|
|
126
|
+
if len(compressed)+1<len(encoded):
|
|
127
|
+
encoded, encoding = compressed,'gz'
|
|
128
|
+
path = '/-/g/'+request.operation+'/'+encoding+'/'+encoded
|
|
129
|
+
limits = await self.discover()
|
|
130
|
+
require(len(path.encode())<=limits.max_path_bytes,'path_too_large')
|
|
131
|
+
return decode_result(await self._json('GET',path))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class GraphQLTransport(HTTPTransport):
|
|
135
|
+
name = 'graphql'
|
|
136
|
+
|
|
137
|
+
async def call(self, request):
|
|
138
|
+
self._secure_delivery(request)
|
|
139
|
+
effect = await self._effect(request.operation)
|
|
140
|
+
kind = 'query' if effect=='read' else 'mutation'
|
|
141
|
+
payload = {'query':kind+' MsgOperation($packet: JSON!) { call(packet: $packet) }',
|
|
142
|
+
'variables':{'packet':wire(request)}}
|
|
143
|
+
endpoint = '/_read/graphql' if effect=='read' else '/-/graphql'
|
|
144
|
+
result = await self._json('POST',endpoint,body=payload)
|
|
145
|
+
require(isinstance(result, dict), 'invalid_graphql_result')
|
|
146
|
+
errors = result.get('errors')
|
|
147
|
+
require(errors is None or isinstance(errors, list), 'invalid_graphql_result')
|
|
148
|
+
if errors:
|
|
149
|
+
require(isinstance(errors, list) and isinstance(errors[0], dict),
|
|
150
|
+
'invalid_graphql_result')
|
|
151
|
+
extensions = errors[0].get('extensions')
|
|
152
|
+
code = extensions.get('code') if isinstance(extensions, dict) else None
|
|
153
|
+
raise Failure(safe_error_code(code, 'graphql_error'))
|
|
154
|
+
data = result.get('data')
|
|
155
|
+
require(isinstance(data, dict) and isinstance(data.get('call'), dict),
|
|
156
|
+
'invalid_graphql_result')
|
|
157
|
+
return decode_result(data['call'])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MCPHTTPTransport(HTTPTransport):
|
|
161
|
+
name = 'mcp_http'
|
|
162
|
+
|
|
163
|
+
async def call(self, request):
|
|
164
|
+
self._secure_delivery(request)
|
|
165
|
+
await self._effect(request.operation)
|
|
166
|
+
from msg.transports.mcp import PROTOCOL_VERSION
|
|
167
|
+
tool_name = request.operation if request.contract_version==1 else (
|
|
168
|
+
f'{request.operation}@{request.contract_version}')
|
|
169
|
+
value = await self._json('POST','/-/mcp',body={'jsonrpc':'2.0','id':request.request_id,
|
|
170
|
+
'method':'tools/call','params':{'name':tool_name,'arguments':{'packet':wire(request)}}},
|
|
171
|
+
headers={'Accept':'application/json, text/event-stream','MCP-Protocol-Version':PROTOCOL_VERSION})
|
|
172
|
+
require(isinstance(value, dict), 'invalid_mcp_result')
|
|
173
|
+
if 'error' in value:
|
|
174
|
+
error = value['error']
|
|
175
|
+
code = error.get('message') if isinstance(error, dict) else None
|
|
176
|
+
raise Failure(safe_error_code(code, 'mcp_error'))
|
|
177
|
+
result = value.get('result')
|
|
178
|
+
require(isinstance(result, dict) and isinstance(result.get('structuredContent'), dict),
|
|
179
|
+
'invalid_mcp_result')
|
|
180
|
+
return decode_result(result['structuredContent'])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
TRANSPORTS = {'http':HTTPTransport,'path_get':PathGETTransport,'graphql':GraphQLTransport,'mcp_http':MCPHTTPTransport}
|