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/market/policy.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Small, immutable policies. No expressions, callbacks or model discretion."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from msg.core.codec import canonical, digest, loads
|
|
5
|
+
from msg.core.errors import require
|
|
6
|
+
from msg.core.query import QuerySession
|
|
7
|
+
|
|
8
|
+
DEFAULT_POLICY = {
|
|
9
|
+
'id': 'dispute-v1', 'version': 1,
|
|
10
|
+
'funding_timeout_seconds': 900, 'delivery_timeout_seconds': 86400,
|
|
11
|
+
'case_timeout_seconds': 604800, 'decision_lifetime_seconds': 86400,
|
|
12
|
+
'panel_size': 3, 'quorum': 2, 'candidates': [],
|
|
13
|
+
'allow_appeal': False, 'appeal_seconds': 3600,
|
|
14
|
+
'selection': 'sha256-order-policy-round-subject-v1',
|
|
15
|
+
'member_invalidation': 'hold-no-replacement-v1',
|
|
16
|
+
'rules': 'objective-faults-v1',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate(policy):
|
|
21
|
+
require(isinstance(policy, dict) and set(policy) == set(DEFAULT_POLICY),
|
|
22
|
+
'arbitration_policy_invalid')
|
|
23
|
+
require(isinstance(policy['id'], str) and 1 <= len(policy['id']) <= 80 and
|
|
24
|
+
type(policy['version']) is int and policy['version'] == 1 and policy['rules'] == 'objective-faults-v1' and
|
|
25
|
+
policy['selection'] == DEFAULT_POLICY['selection'] and
|
|
26
|
+
policy['member_invalidation'] == DEFAULT_POLICY['member_invalidation'],
|
|
27
|
+
'arbitration_policy_invalid')
|
|
28
|
+
for name, maximum in (('funding_timeout_seconds', 86400),
|
|
29
|
+
('delivery_timeout_seconds', 2592000),
|
|
30
|
+
('case_timeout_seconds', 2592000),
|
|
31
|
+
('decision_lifetime_seconds', 604800),
|
|
32
|
+
('appeal_seconds', 86400)):
|
|
33
|
+
require(type(policy[name]) is int and 60 <= policy[name] <= maximum,
|
|
34
|
+
'arbitration_policy_invalid', name)
|
|
35
|
+
size, quorum, candidates = policy['panel_size'], policy['quorum'], policy['candidates']
|
|
36
|
+
require(type(size) is int and 1 <= size <= 9 and type(quorum) is int and
|
|
37
|
+
size // 2 < quorum <= size and type(policy['allow_appeal']) is bool and
|
|
38
|
+
isinstance(candidates, list) and len(candidates) <= 100 and
|
|
39
|
+
all(isinstance(s, str) and s.startswith('u_') and s != 'u_root'
|
|
40
|
+
for s in candidates) and len(candidates) == len(set(candidates)),
|
|
41
|
+
'arbitration_policy_invalid')
|
|
42
|
+
require(not policy['allow_appeal'] or
|
|
43
|
+
policy['decision_lifetime_seconds'] > policy['appeal_seconds'],
|
|
44
|
+
'arbitration_policy_invalid')
|
|
45
|
+
return policy
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_policy(tx: QuerySession, policy_id: str):
|
|
49
|
+
row = tx.one('SELECT body,digest FROM arbitration_policies WHERE id=?', (policy_id,))
|
|
50
|
+
if row:
|
|
51
|
+
policy = loads(row[0])
|
|
52
|
+
require(digest(policy) == row[1], 'arbitration_policy_corrupt')
|
|
53
|
+
else:
|
|
54
|
+
require(policy_id == DEFAULT_POLICY['id'], 'arbitration_policy_unknown')
|
|
55
|
+
policy = loads(canonical(DEFAULT_POLICY))
|
|
56
|
+
return validate(policy)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def snapshot(tx, policy_id):
|
|
60
|
+
policy = load_policy(tx, policy_id)
|
|
61
|
+
# Freeze the grant epoch, not merely the subject: revoke+regrant cannot revive
|
|
62
|
+
# a vote or silently replace a member in an existing order.
|
|
63
|
+
epochs = {}
|
|
64
|
+
for candidate in policy['candidates']:
|
|
65
|
+
row = tx.one('SELECT epoch,active FROM arbitrator_roles WHERE subject=?', (candidate,))
|
|
66
|
+
if row and row[1]:
|
|
67
|
+
epochs[candidate] = row[0]
|
|
68
|
+
return {'policy': policy, 'policy_digest': digest(policy), 'candidate_epochs': epochs}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def contract(tx, order_id):
|
|
72
|
+
row = tx.one('SELECT body,digest FROM order_contracts WHERE order_id=?', (order_id,))
|
|
73
|
+
require(row is not None, 'order_contract_required')
|
|
74
|
+
body = loads(row[0])
|
|
75
|
+
require(digest(body) == row[1] and body['order_id'] == order_id,
|
|
76
|
+
'order_contract_corrupt')
|
|
77
|
+
validate_projection(tx, body)
|
|
78
|
+
return body
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def delivery_snapshot(tx, order_id):
|
|
82
|
+
"""Commit delivery identity and prepared bytes, excluding subsequent buyer ACK."""
|
|
83
|
+
from msg.plugins.delivery import _delivery
|
|
84
|
+
delivery = _delivery(tx, order_id)
|
|
85
|
+
return digest(None if delivery is None else {
|
|
86
|
+
key: value for key, value in delivery.items()
|
|
87
|
+
if key not in {'state', 'claimed_at', 'receipt'}})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_projection(tx, locked):
|
|
91
|
+
"""A restored mutable projection cannot replace the immutable checkout facts."""
|
|
92
|
+
require(locked.get('version') == 3, 'order_contract_version')
|
|
93
|
+
listing = locked['listing']
|
|
94
|
+
expected = {name: locked[name] for name in (
|
|
95
|
+
'buyer', 'seller', 'listing_id', 'listing_revision', 'package_id',
|
|
96
|
+
'package_revision', 'package_digest', 'quantity', 'total_price_minor',
|
|
97
|
+
'escrow_subject', 'terms_digest', 'created_at')}
|
|
98
|
+
expected.update(unit_price_minor=listing['price_minor'],
|
|
99
|
+
currency_id=listing['currency_id'], escrow_policy=listing['escrow_policy'],
|
|
100
|
+
dispute_policy=listing['dispute_policy'])
|
|
101
|
+
mutable = ('state', 'receipt_refs', 'settled_at', 'payment_transaction_id',
|
|
102
|
+
'payment_intent_digest', 'funded_at', 'delivered_at')
|
|
103
|
+
columns = (*expected, *mutable)
|
|
104
|
+
row = tx.one('SELECT ' + ','.join(columns) + ' FROM store_orders WHERE id=?',
|
|
105
|
+
(locked['order_id'],))
|
|
106
|
+
require(row is not None, 'order_contract_mismatch')
|
|
107
|
+
order = dict(zip(columns, row))
|
|
108
|
+
require(all(order[name] == value for name, value in expected.items()),
|
|
109
|
+
'order_contract_mismatch')
|
|
110
|
+
if order['payment_transaction_id'] is not None:
|
|
111
|
+
payment = tx.one('''SELECT debit_account,credit_account,amount_minor,currency_id,
|
|
112
|
+
reference,committed_at FROM money_ledger WHERE id=?''',
|
|
113
|
+
(order['payment_transaction_id'],))
|
|
114
|
+
require(payment == (locked['buyer'], locked['escrow_subject'],
|
|
115
|
+
locked['total_price_minor'], listing['currency_id'],
|
|
116
|
+
'order_fund:' + locked['order_id'], order['funded_at']), 'order_payment_mismatch')
|
|
117
|
+
fact = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (locked['order_id'],))
|
|
118
|
+
if fact is None:
|
|
119
|
+
require(order['state'] not in {'settled', 'refunded'}, 'order_settlement_missing')
|
|
120
|
+
return
|
|
121
|
+
fact = loads(fact[0])
|
|
122
|
+
receipts = fact['receipts']
|
|
123
|
+
references = [order['payment_transaction_id']] + [r['body']['transaction_id'] for r in receipts]
|
|
124
|
+
require(fact['order_id'] == locked['order_id'] and
|
|
125
|
+
all(type(fact[name]) is int and fact[name] >= 0
|
|
126
|
+
for name in ('refund_minor', 'release_minor')), 'order_settlement_mismatch')
|
|
127
|
+
# Authentic ledger receipts alone are not enough: each one must be the
|
|
128
|
+
# exact escrow leg this fact claims, not e.g. the funding payment reused.
|
|
129
|
+
legs = [leg for leg in (
|
|
130
|
+
('refund', locked['buyer'], fact['refund_minor'], 'order_refund:' + locked['order_id']),
|
|
131
|
+
('transfer', locked['seller'], fact['release_minor'], 'order_release:' + locked['order_id']),
|
|
132
|
+
) if leg[2]]
|
|
133
|
+
require([(r['body']['kind'], r['body']['from_subject'], r['body']['to_subject'],
|
|
134
|
+
r['body']['amount_minor'], r['body']['currency_id'], r['body']['reference'])
|
|
135
|
+
for r in receipts] ==
|
|
136
|
+
[(kind, locked['escrow_subject'], recipient, amount, listing['currency_id'], reference)
|
|
137
|
+
for kind, recipient, amount, reference in legs], 'order_settlement_mismatch')
|
|
138
|
+
require(fact['refund_minor'] + fact['release_minor'] == locked['total_price_minor'] and
|
|
139
|
+
order['state'] == ('refunded' if fact['release_minor'] == 0 else 'settled') and
|
|
140
|
+
order['settled_at'] == fact['at'] and loads(order['receipt_refs']) == references and
|
|
141
|
+
all(order[name] == value for name, value in fact['order_facts'].items()) and
|
|
142
|
+
delivery_snapshot(tx, locked['order_id']) == fact['delivery_snapshot'],
|
|
143
|
+
'order_settlement_mismatch')
|
|
144
|
+
for receipt in receipts:
|
|
145
|
+
row = tx.one('SELECT receipt FROM money_ledger WHERE id=?',
|
|
146
|
+
(receipt['body']['transaction_id'],))
|
|
147
|
+
require(row is not None and loads(row[0]) == receipt, 'order_settlement_mismatch')
|
msg/market/rationale.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Pinned, case-private reasons are part of a signed arbitration allocation.
|
|
2
|
+
|
|
3
|
+
This is a reference to an ordinary immutable File revision, not executable policy.
|
|
4
|
+
The case grants parties/panel access to that one blob, never the author's folder.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from msg.core.codec import canonical, decode, digest, loads, parse_time, wire
|
|
9
|
+
from msg.core.errors import Failure, require
|
|
10
|
+
from msg.core.models import BlobRef, HandlerOutput, ResourceRef
|
|
11
|
+
from msg.market.delivery import verify_blob
|
|
12
|
+
from msg.plugins.common import check_access
|
|
13
|
+
from msg.plugins.orders import _subject
|
|
14
|
+
from msg.plugins.schemas import IDENTIFIER, obj
|
|
15
|
+
|
|
16
|
+
PINNED_REF = obj({'id': IDENTIFIER, 'revision': IDENTIFIER}, ('id', 'revision'))
|
|
17
|
+
MAX_REASON_BYTES = 65536
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def evidence_id(case, ref):
|
|
21
|
+
return 'rationale_' + digest({'case_id': case['id'], 'round': case['round'],
|
|
22
|
+
'ref': wire(ref)})[7:]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record(tx, case, proposal):
|
|
26
|
+
"""Check immutable bindings without reinterpreting an old grant or policy."""
|
|
27
|
+
ref = proposal['rationale_ref']
|
|
28
|
+
row = tx.one('SELECT author,visibility,body FROM arbitration_evidence WHERE id=? AND case_id=?',
|
|
29
|
+
(evidence_id(case, ref), case['id']))
|
|
30
|
+
require(row is not None, 'decision_rationale_missing')
|
|
31
|
+
body = loads(row[2])
|
|
32
|
+
require(row[0] in case['panels'][str(case['round'])] and row[1] == 'parties' and
|
|
33
|
+
body['kind'] == 'rationale' and body['round'] == case['round'] and
|
|
34
|
+
body['source'] == ref and body['blob']['digest'] == proposal['rationale_digest'],
|
|
35
|
+
'decision_rationale_mismatch')
|
|
36
|
+
return body
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def verified(app, tx, case, proposal):
|
|
40
|
+
body = record(tx, case, proposal)
|
|
41
|
+
try:
|
|
42
|
+
await verify_blob(app, decode(BlobRef, body['blob']))
|
|
43
|
+
except Failure as exc:
|
|
44
|
+
if exc.code == 'package_missing':
|
|
45
|
+
raise Failure('decision_rationale_missing') from None
|
|
46
|
+
if exc.code == 'package_digest_mismatch':
|
|
47
|
+
raise Failure('decision_rationale_mismatch') from None
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install(app, op):
|
|
52
|
+
@op('orders.dispute_rationale', obj({'case_id': IDENTIFIER, 'ref': PINNED_REF},
|
|
53
|
+
('case_id', 'ref')), signature=True)
|
|
54
|
+
async def deposit(ctx, request, tx):
|
|
55
|
+
from msg.market.arbitration import owned_case, panel_valid
|
|
56
|
+
actor = _subject(ctx)
|
|
57
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'])
|
|
58
|
+
require(actor in case['panels'][str(case['round'])] and await panel_valid(tx, case),
|
|
59
|
+
'arbitrator_required')
|
|
60
|
+
require(case['state'] == 'open' and
|
|
61
|
+
ctx.now < parse_time(case['round_deadlines'][str(case['round'])]),
|
|
62
|
+
'case_evidence_closed')
|
|
63
|
+
ref = decode(ResourceRef, request.arguments['ref'])
|
|
64
|
+
resource = await tx.resource(ref.id)
|
|
65
|
+
require(resource.owner == actor and resource.type in {'file', 'attachment'} and
|
|
66
|
+
resource.state == 'active', 'rationale_not_owned')
|
|
67
|
+
await check_access(app, ctx, request, tx, ref.id, 'read')
|
|
68
|
+
blob = (await tx.revision(ref)).content
|
|
69
|
+
require(0 < blob.size <= MAX_REASON_BYTES and
|
|
70
|
+
blob.media_type in {'text/plain', 'text/markdown'}, 'rationale_text_required')
|
|
71
|
+
await verify_blob(app, blob)
|
|
72
|
+
raw = await app.contents.read_bytes(blob, limit=MAX_REASON_BYTES)
|
|
73
|
+
try:
|
|
74
|
+
require(bool(raw.decode('utf-8').strip()), 'rationale_text_required')
|
|
75
|
+
except UnicodeDecodeError:
|
|
76
|
+
raise Failure('rationale_text_required') from None
|
|
77
|
+
eid = evidence_id(case, wire(ref))
|
|
78
|
+
previous = tx.one('SELECT body FROM arbitration_evidence WHERE id=?', (eid,))
|
|
79
|
+
if previous is None:
|
|
80
|
+
require(tx.one('SELECT COUNT(*) FROM arbitration_evidence WHERE case_id=? AND author=?',
|
|
81
|
+
(case['id'], actor))[0] < 64, 'case_evidence_limit')
|
|
82
|
+
await app.contents.pin(blob, eid)
|
|
83
|
+
body = {'kind': 'rationale', 'blob': wire(blob), 'source': wire(ref),
|
|
84
|
+
'round': case['round'], 'at': wire(ctx.now),
|
|
85
|
+
'author_request_digest': request.payload_digest}
|
|
86
|
+
tx.execute('INSERT INTO arbitration_evidence(id,case_id,author,visibility,body) '
|
|
87
|
+
"VALUES (?,?,?,'parties',?)",
|
|
88
|
+
(eid, case['id'], actor, canonical(body).decode()), write=True)
|
|
89
|
+
binding = {'rationale_ref': wire(ref), 'rationale_digest': blob.digest}
|
|
90
|
+
record(tx, case, binding)
|
|
91
|
+
return HandlerOutput(data={**binding, 'evidence_id': eid})
|
msg/market/references.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SQL-authoritative market content roots, shared by backup and collection.
|
|
2
|
+
|
|
3
|
+
Order IDs, digests and pins never confer read access. Pins protect a blob before
|
|
4
|
+
SQL commits; this inventory protects it after the originating file is purged.
|
|
5
|
+
"""
|
|
6
|
+
from msg.core.codec import loads
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def content_references(execute):
|
|
10
|
+
for table, column in (('store_packages', 'payload_refs'),
|
|
11
|
+
('store_deliveries', 'payload_refs')):
|
|
12
|
+
for (raw,) in execute(f'SELECT {column} FROM {table} ORDER BY id'):
|
|
13
|
+
for ref in loads(raw):
|
|
14
|
+
yield ref['blob']
|
|
15
|
+
# Legacy archives can be inspected before the new schema is installed.
|
|
16
|
+
exists = next(iter(execute("SELECT to_regclass('arbitration_evidence')")))[0]
|
|
17
|
+
if exists is not None:
|
|
18
|
+
for (raw,) in execute('SELECT body FROM arbitration_evidence ORDER BY id'):
|
|
19
|
+
yield loads(raw)['blob']
|
msg/market/targets.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Buyer-owned endpoint binding shared by checkout, delivery and SMTP worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import canonical, decode, digest, loads
|
|
7
|
+
from msg.core.errors import Failure, require
|
|
8
|
+
from msg.core.models import EffectJob
|
|
9
|
+
from msg.market.delivery_targets import account_email, mail_enabled, pickup_url, validate_address
|
|
10
|
+
from msg.market.policy import contract
|
|
11
|
+
from msg.plugins.common import new_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _email(tx, subject):
|
|
15
|
+
generation, settings = account_email(tx, subject)
|
|
16
|
+
if settings is None:
|
|
17
|
+
return None, None
|
|
18
|
+
endpoint = 'em_' + digest((subject, settings.address, settings.verified_at, generation))[7:39]
|
|
19
|
+
return settings, endpoint
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def target_for(app, tx, buyer, email_address=None):
|
|
23
|
+
handle = (await tx.resource(buyer)).name
|
|
24
|
+
target = {'subject_id': buyer, 'channel': 'site', 'handle_snapshot': handle,
|
|
25
|
+
'email': {'state': 'disabled'}}
|
|
26
|
+
if email_address is None:
|
|
27
|
+
return target
|
|
28
|
+
validate_address(email_address)
|
|
29
|
+
email, endpoint = _email(tx, buyer)
|
|
30
|
+
verified = (email is not None and email.subject_id == buyer and
|
|
31
|
+
email.address == email_address and email.verified_at is not None)
|
|
32
|
+
target['email'] = {'state': 'pending' if mail_enabled(app) else 'disabled',
|
|
33
|
+
'subject_id': buyer, 'address_snapshot': email_address,
|
|
34
|
+
'endpoint_id': endpoint if verified else None}
|
|
35
|
+
# Verification is a separate, order-information-free external effect.
|
|
36
|
+
return target
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_target(tx, order, target):
|
|
40
|
+
tx.execute('UPDATE store_orders SET delivery_target=? WHERE id=?',
|
|
41
|
+
(canonical(target).decode(), order['id']), write=True)
|
|
42
|
+
order['delivery_target'] = target
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_target(tx, order, *, email=False, encryption=False):
|
|
46
|
+
target = order['delivery_target']
|
|
47
|
+
require(target.get('subject_id') == order['buyer'] and target.get('channel') == 'site',
|
|
48
|
+
'delivery_recipient_mismatch')
|
|
49
|
+
if encryption:
|
|
50
|
+
locked = contract(tx, order['id']).get('recipient_key')
|
|
51
|
+
row = tx.one('SELECT subject,recipient,retired_at FROM encryption_subkeys WHERE key_id=?',
|
|
52
|
+
(locked['key_id'],)) if locked else None
|
|
53
|
+
require(row is not None and row[0] == order['buyer'] and row[1] == locked['recipient']
|
|
54
|
+
and row[2] is None, 'delivery_recipient_mismatch')
|
|
55
|
+
if email:
|
|
56
|
+
chosen = target.get('email', {})
|
|
57
|
+
require(not chosen.get('notification_disabled'), 'delivery_recipient_mismatch')
|
|
58
|
+
endpoint_id = chosen.get('endpoint_id') or ''
|
|
59
|
+
if endpoint_id.startswith('dep_'):
|
|
60
|
+
row = tx.one('SELECT subject,address,active FROM delivery_email_endpoints WHERE id=?',
|
|
61
|
+
(endpoint_id,))
|
|
62
|
+
require(row is not None and row[2] and
|
|
63
|
+
row[0] == chosen.get('subject_id') == order['buyer'] and
|
|
64
|
+
row[1] == chosen.get('address_snapshot'), 'delivery_recipient_mismatch')
|
|
65
|
+
validate_address(row[1])
|
|
66
|
+
return row[1]
|
|
67
|
+
current, endpoint = _email(tx, order['buyer'])
|
|
68
|
+
require(chosen.get('subject_id') == order['buyer'] and current is not None and
|
|
69
|
+
current.subject_id == order['buyer'] and current.verified_at is not None and
|
|
70
|
+
chosen.get('endpoint_id') == endpoint and
|
|
71
|
+
chosen.get('address_snapshot') == current.address,
|
|
72
|
+
'delivery_recipient_mismatch')
|
|
73
|
+
validate_address(current.address)
|
|
74
|
+
return current.address
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def enqueue_notification(app, tx, ctx, request, order):
|
|
79
|
+
chosen = order['delivery_target'].get('email', {})
|
|
80
|
+
if (not mail_enabled(app) or not chosen.get('endpoint_id') or
|
|
81
|
+
chosen.get('notification_disabled')):
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
validate_target(tx, order, email=True)
|
|
85
|
+
except Failure as exc:
|
|
86
|
+
if exc.code != 'delivery_recipient_mismatch':
|
|
87
|
+
raise
|
|
88
|
+
save_target(tx, order, {**order['delivery_target'], 'email': {
|
|
89
|
+
**chosen, 'state': 'pending', 'error': exc.code}})
|
|
90
|
+
return
|
|
91
|
+
# Only the buyer can bind/queue the auxiliary channel, even when the seller
|
|
92
|
+
# uploads ciphertext later. Checkout's signed principal is persisted privately.
|
|
93
|
+
locked = contract(tx, order['id'])
|
|
94
|
+
from msg.core.models import Principal
|
|
95
|
+
principal = decode(Principal, locked['buyer_principal'])
|
|
96
|
+
dedupe = 'market-mail:'+order['id']+':'+chosen['endpoint_id']
|
|
97
|
+
if tx.one('SELECT 1 FROM jobs WHERE dedupe=?', (dedupe,)):
|
|
98
|
+
return
|
|
99
|
+
job = EffectJob(id=new_id('job'), event_id='delivery:'+order['id'], kind='market_mail',
|
|
100
|
+
dedupe_key=dedupe, principal=principal,
|
|
101
|
+
operation='orders.buy', arguments={'order_id': order['id'], 'endpoint_id': chosen['endpoint_id']},
|
|
102
|
+
state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None)
|
|
103
|
+
await tx.enqueue(job)
|
|
104
|
+
target = {**order['delivery_target'], 'email': {**chosen, 'state': 'queued'}}
|
|
105
|
+
save_target(tx, order, target)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def render_notification(app, tx, job):
|
|
109
|
+
from msg.plugins.delivery import _delivery
|
|
110
|
+
from msg.plugins.orders import _row
|
|
111
|
+
order = _row(tx, job.arguments['order_id'], job.principal.subject)
|
|
112
|
+
require(order['buyer'] == job.principal.subject and job.principal.actor == order['buyer'],
|
|
113
|
+
'delivery_recipient_mismatch')
|
|
114
|
+
delivery = _delivery(tx, order['id'])
|
|
115
|
+
require(order['state'] in {'delivered','accepted','settled'} and delivery is not None and
|
|
116
|
+
delivery['recipient_subject'] == order['buyer'], 'delivery_not_available')
|
|
117
|
+
require(order['delivery_target'].get('email', {}).get('endpoint_id') == job.arguments['endpoint_id'],
|
|
118
|
+
'delivery_recipient_mismatch')
|
|
119
|
+
locked = contract(tx, order['id'])
|
|
120
|
+
address = validate_target(tx, order, email=True,
|
|
121
|
+
encryption=locked['listing']['delivery_mode'] == 'sealed_manual')
|
|
122
|
+
# A stable site URL is not a capability. GET always requires fresh authentication.
|
|
123
|
+
link = pickup_url(app, order['id'])
|
|
124
|
+
body = {'order_id': order['id'], 'handle': locked['handle_snapshot'], 'pickup': link}
|
|
125
|
+
return replace(job, kind='mail', arguments={'recipient': address,
|
|
126
|
+
'recipient_subject': order['buyer'], 'subject': 'msg order notification',
|
|
127
|
+
'text': canonical(body).decode()})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def notification_view(tx, order):
|
|
131
|
+
"""Project current job facts; never persist SMTP state into the order."""
|
|
132
|
+
chosen = dict(order['delivery_target'].get('email', {'state': 'disabled'}))
|
|
133
|
+
if chosen.get('notification_disabled'):
|
|
134
|
+
return {**chosen, 'state': 'disabled'}
|
|
135
|
+
endpoint = chosen.get('endpoint_id')
|
|
136
|
+
if not endpoint:
|
|
137
|
+
return chosen
|
|
138
|
+
dedupe = 'market-mail:' + order['id'] + ':' + endpoint
|
|
139
|
+
row = tx.one('SELECT body FROM jobs WHERE dedupe=?', (dedupe,))
|
|
140
|
+
chosen.pop('last_result', None)
|
|
141
|
+
if row is None:
|
|
142
|
+
# Old cached state is not evidence that an external delivery completed.
|
|
143
|
+
return {**chosen, 'state': 'disabled' if chosen.get('state') == 'disabled' else 'pending'}
|
|
144
|
+
job = decode(EffectJob, loads(row[0]))
|
|
145
|
+
require(job.kind == 'market_mail' and job.dedupe_key == dedupe and
|
|
146
|
+
job.principal.actor == job.principal.subject == order['buyer'] and
|
|
147
|
+
job.arguments.get('order_id') == order['id'] and
|
|
148
|
+
job.arguments.get('endpoint_id') == endpoint,
|
|
149
|
+
'delivery_recipient_mismatch')
|
|
150
|
+
result = tx.setting('job_status:' + job.id, {})
|
|
151
|
+
code = result.get('code')
|
|
152
|
+
state = ('disabled' if code == 'mail_disabled' else
|
|
153
|
+
'smtp_accepted' if job.state == 'done' and code == 'sent' else
|
|
154
|
+
'delivered_unknown' if job.state in {'done', 'uncertain'} else
|
|
155
|
+
'failed' if job.state == 'failed' else
|
|
156
|
+
'pending' if code is not None else 'queued')
|
|
157
|
+
return {**chosen, 'state': state, **({'last_result': code} if code is not None else {})}
|
msg/plugins/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The installed plugin order has one owner for every runtime role."""
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
from msg.core.errors import require
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
BUILTINS = (
|
|
8
|
+
'identity', 'content', 'discussion', 'communication', 'discovery',
|
|
9
|
+
'achievements', 'recovery', 'sharing', 'money', 'offers', 'store',
|
|
10
|
+
'bounty', 'orders', 'delivery', 'transfer', 'extensions', 'system', 'batch',
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install_registry(context, plugins):
|
|
15
|
+
"""Register installed code only; the context determines available capabilities.
|
|
16
|
+
|
|
17
|
+
The write Application supplies its own context. A read-only host supplies a
|
|
18
|
+
disposable registration context and a registry which discards callbacks.
|
|
19
|
+
No storage connection, runtime secret or business operation is needed here.
|
|
20
|
+
"""
|
|
21
|
+
from msg.security.capabilities import install_capabilities
|
|
22
|
+
configured = set(plugins)
|
|
23
|
+
require(configured <= set(BUILTINS), 'unknown_plugin')
|
|
24
|
+
for name in BUILTINS:
|
|
25
|
+
if name in configured:
|
|
26
|
+
importlib.import_module('msg.plugins.' + name).install(context)
|
|
27
|
+
install_capabilities(context.registry)
|
|
28
|
+
context.registry.freeze()
|