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/delivery.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Automatic managed packages and explicitly accepted ciphertext/service evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import b64, canonical, decode, digest, loads, wire
|
|
7
|
+
from msg.core.errors import Failure, require
|
|
8
|
+
from msg.core.models import BlobRef, HandlerOutput, ResourceRef
|
|
9
|
+
from msg.market.escrow import settle, transition
|
|
10
|
+
from msg.market.orders import HASH, ORDER
|
|
11
|
+
from msg.market.policy import contract
|
|
12
|
+
from msg.market.targets import enqueue_notification, validate_target
|
|
13
|
+
from msg.plugins.common import check_access, new_id
|
|
14
|
+
from msg.plugins.delivery import _buyer_order, _delivery
|
|
15
|
+
from msg.plugins.orders import _row, _subject, _viewer
|
|
16
|
+
from msg.plugins.schemas import IDENTIFIER, REF, obj
|
|
17
|
+
from msg.plugins.store import _package_row
|
|
18
|
+
|
|
19
|
+
INLINE = 65536
|
|
20
|
+
PART = 65536
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def verify_blob(app, blob):
|
|
24
|
+
"""Bounded-memory verification, including content loss after a deposit."""
|
|
25
|
+
hasher, size = hashlib.sha256(), 0
|
|
26
|
+
try:
|
|
27
|
+
async for chunk in app.contents.read(blob):
|
|
28
|
+
hasher.update(chunk)
|
|
29
|
+
size += len(chunk)
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
raise Failure('package_missing') from None
|
|
32
|
+
except Failure as exc:
|
|
33
|
+
if exc.code == 'content_missing':
|
|
34
|
+
raise Failure('package_missing') from None
|
|
35
|
+
if exc.code in {'content_truncated','content_size_mismatch'}:
|
|
36
|
+
raise Failure('package_digest_mismatch') from None
|
|
37
|
+
raise
|
|
38
|
+
require(size == blob.size and 'sha256:'+hasher.hexdigest() == blob.digest,
|
|
39
|
+
'package_digest_mismatch')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def package(app, tx, order):
|
|
43
|
+
row = await _package_row(tx, order['package_id'])
|
|
44
|
+
require(row is not None, 'package_missing')
|
|
45
|
+
require(row[1] == order['listing_id'] and row[3] == order['seller'] and
|
|
46
|
+
row[4] == order['package_revision'] and row[8] == order['package_digest'] and
|
|
47
|
+
row[10] == 'managed_instant' and row[5] in {'file','bundle','text'},
|
|
48
|
+
'package_digest_mismatch')
|
|
49
|
+
refs, manifest = loads(row[7]), loads(row[6])
|
|
50
|
+
snapshot = {'seller': row[3], 'listing_id': row[1], 'listing_revision': row[2],
|
|
51
|
+
'revision': row[4], 'kind': row[5], 'manifest': manifest, 'payload_refs': refs,
|
|
52
|
+
'total_size': row[9], 'delivery_mode': row[10]}
|
|
53
|
+
require(digest(snapshot) == order['package_digest'] and 0 <= row[9] <= 1024**4,
|
|
54
|
+
'package_digest_mismatch')
|
|
55
|
+
require(1 <= len(refs) <= 32, 'package_digest_mismatch')
|
|
56
|
+
total = 0
|
|
57
|
+
for ref in refs:
|
|
58
|
+
blob = decode(BlobRef, ref['blob'])
|
|
59
|
+
await verify_blob(app, blob)
|
|
60
|
+
total += blob.size
|
|
61
|
+
require(total == row[9], 'package_digest_mismatch')
|
|
62
|
+
return row[5], manifest, refs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def body_for(order, delivery_id, kind, manifest, refs):
|
|
66
|
+
return {'delivery_id': delivery_id, 'order_id': order['id'],
|
|
67
|
+
'recipient_subject': order['buyer'], 'kind': kind, 'manifest': manifest,
|
|
68
|
+
'payload_refs': refs, 'package_digest': order['package_digest'] or
|
|
69
|
+
contract_digest(order), 'channel': 'site'}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def contract_digest(order):
|
|
73
|
+
# Non-managed goods have no deposited package. This is a terms commitment,
|
|
74
|
+
# not a false claim that the service/ciphertext content was fixed at checkout.
|
|
75
|
+
return digest({'listing_revision': order['listing_revision'], 'terms_digest': order['terms_digest']})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def prepare(app, tx, ctx, request, order, kind, manifest, refs, *, envelope=None):
|
|
79
|
+
require(order['state'] == 'funded' and order['delivered_at'] is None,
|
|
80
|
+
'order_not_deliverable')
|
|
81
|
+
validate_target(tx, order, encryption=envelope is not None)
|
|
82
|
+
delivery_id = new_id('dlv')
|
|
83
|
+
for ref in refs:
|
|
84
|
+
await app.contents.pin(decode(BlobRef, ref['blob']), delivery_id)
|
|
85
|
+
body = body_for(order, delivery_id, kind, manifest, refs)
|
|
86
|
+
tx.execute('''INSERT INTO store_deliveries
|
|
87
|
+
(id,order_id,recipient_subject,kind,payload_refs,manifest,package_digest,
|
|
88
|
+
delivery_digest,channel,state,prepared_at,claimed_at,receipt)
|
|
89
|
+
VALUES (?,?,?,?,?,?,?,?,'site','prepared',?,NULL,NULL)''',
|
|
90
|
+
(delivery_id,order['id'],order['buyer'],kind,canonical(refs).decode(),
|
|
91
|
+
canonical(manifest).decode(),body['package_digest'],digest(body),wire(ctx.now)), write=True)
|
|
92
|
+
if envelope:
|
|
93
|
+
tx.execute('INSERT INTO delivery_envelopes(order_id,body) VALUES (?,?)',
|
|
94
|
+
(order['id'],canonical(envelope).decode()), write=True)
|
|
95
|
+
await transition(tx, order, 'delivered', now=ctx.now, actor=ctx.principal.actor,
|
|
96
|
+
request_id=request.request_id, reason='verified_delivery')
|
|
97
|
+
tx.execute('UPDATE store_orders SET delivered_at=? WHERE id=?',
|
|
98
|
+
(wire(ctx.now),order['id']), write=True)
|
|
99
|
+
order['delivered_at'] = wire(ctx.now)
|
|
100
|
+
# Inbox is a minimal reference to the one authoritative order, not a copy.
|
|
101
|
+
message = {'id': new_id('message'), 'sender': order['seller'], 'actor': order['seller'],
|
|
102
|
+
'recipient': order['buyer'], 'order_id': order['id'], 'time': wire(ctx.now),
|
|
103
|
+
'resource': wire(ResourceRef(id=order['buyer'])), 'state': 'delivered', 'source': 'system',
|
|
104
|
+
'href': '/_orders/'+order['id']+'/_delivery'}
|
|
105
|
+
tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
|
|
106
|
+
(message['id'],order['seller'],order['buyer'],order['buyer'],
|
|
107
|
+
'delivery:'+order['id'],canonical(message).decode()), write=True)
|
|
108
|
+
await enqueue_notification(app, tx, ctx, request, order)
|
|
109
|
+
return _delivery(tx, order['id'])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def automatic(app, tx, ctx, request, order):
|
|
113
|
+
"""An authenticated payment authorizes the pinned managed-instant policy.
|
|
114
|
+
|
|
115
|
+
Only verified objective storage faults refund. An arbitrary exception rolls
|
|
116
|
+
back the executor transaction, rather than turning software bugs into payouts.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
kind, manifest, refs = await package(app, tx, order)
|
|
120
|
+
except Failure as exc:
|
|
121
|
+
if exc.code not in {'package_missing','package_digest_mismatch'}:
|
|
122
|
+
raise
|
|
123
|
+
await settle(app, tx, order, now=ctx.now, actor=order['buyer'],
|
|
124
|
+
request_id=request.request_id, reason=exc.code, refund_minor=order['total_price_minor'])
|
|
125
|
+
return
|
|
126
|
+
await prepare(app, tx, ctx, request, order, kind, manifest, refs)
|
|
127
|
+
await transition(tx, order, 'accepted', now=ctx.now, actor=order['buyer'],
|
|
128
|
+
request_id=request.request_id, reason='managed_instant_verified')
|
|
129
|
+
await settle(app, tx, order, now=ctx.now, actor=order['buyer'],
|
|
130
|
+
request_id=request.request_id, reason='managed_instant_verified')
|
|
131
|
+
# Delivery remains prepared, not claimed. Server fulfilment is not buyer ACK.
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def verified(app, tx, order, *, verify_bytes=True):
|
|
135
|
+
delivery = _delivery(tx, order['id'])
|
|
136
|
+
require(delivery is not None, 'delivery_not_found')
|
|
137
|
+
validate_target(tx, order)
|
|
138
|
+
require(delivery['recipient_subject'] == order['buyer'], 'delivery_recipient_mismatch')
|
|
139
|
+
expected = body_for(order, delivery['id'], delivery['kind'],
|
|
140
|
+
delivery['manifest'], delivery['payload_refs'])
|
|
141
|
+
require(delivery['package_digest'] == expected['package_digest'] and
|
|
142
|
+
delivery['delivery_digest'] == digest(expected), 'delivery_digest_mismatch')
|
|
143
|
+
locked = contract(tx, order['id'])
|
|
144
|
+
if locked['listing']['delivery_mode'] == 'managed_instant':
|
|
145
|
+
row = await _package_row(tx, order['package_id'])
|
|
146
|
+
require(row is not None and row[8] == order['package_digest'] and
|
|
147
|
+
loads(row[6]) == delivery['manifest'] and loads(row[7]) == delivery['payload_refs'],
|
|
148
|
+
'delivery_digest_mismatch')
|
|
149
|
+
elif locked['listing']['delivery_mode'] == 'sealed_manual':
|
|
150
|
+
envelope = tx.one('SELECT body FROM delivery_envelopes WHERE order_id=?', (order['id'],))
|
|
151
|
+
require(envelope is not None, 'delivery_digest_mismatch')
|
|
152
|
+
envelope = loads(envelope[0])
|
|
153
|
+
require(envelope['recipient_subject'] == order['buyer'] and
|
|
154
|
+
envelope['recipient_key'] == locked['recipient_key'] and
|
|
155
|
+
envelope['ciphertext_ref'] == delivery['payload_refs'][0],
|
|
156
|
+
'delivery_recipient_mismatch')
|
|
157
|
+
if verify_bytes:
|
|
158
|
+
for ref in delivery['payload_refs']:
|
|
159
|
+
await verify_blob(app, decode(BlobRef, ref['blob']))
|
|
160
|
+
return delivery
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def read(app, tx, ctx, request):
|
|
164
|
+
buyer = _viewer(ctx)
|
|
165
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
166
|
+
delivery = await verified(app, tx, order)
|
|
167
|
+
payloads, budget = [], INLINE
|
|
168
|
+
for index, ref in enumerate(delivery['payload_refs']):
|
|
169
|
+
blob = decode(BlobRef, ref['blob'])
|
|
170
|
+
item = {'id': ref['id'], 'revision': ref['revision'], 'digest': blob.digest,
|
|
171
|
+
'size': blob.size, 'media_type': blob.media_type, 'payload_index': index}
|
|
172
|
+
if blob.size <= budget:
|
|
173
|
+
item['data'] = b64(await app.contents.read_bytes(blob, limit=INLINE))
|
|
174
|
+
budget -= blob.size
|
|
175
|
+
else:
|
|
176
|
+
item['transfer'] = {'operation':'delivery.transfer_open','arguments':{
|
|
177
|
+
'order_id':order['id'],'delivery_digest':delivery['delivery_digest'],
|
|
178
|
+
'payload_index':index}}
|
|
179
|
+
item['next'] = {'operation': 'delivery.part_get', 'arguments': {
|
|
180
|
+
'order_id': order['id'], 'delivery_digest': delivery['delivery_digest'],
|
|
181
|
+
'payload_index': index, 'offset': 0, 'length': min(PART,blob.size)}}
|
|
182
|
+
payloads.append(item)
|
|
183
|
+
result = {k:v for k,v in delivery.items() if k not in {'id','payload_refs','receipt'}}
|
|
184
|
+
result.update(delivery_id=delivery['id'], payloads=payloads)
|
|
185
|
+
return HandlerOutput(data={'delivery': result})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def accept(app, tx, ctx, request):
|
|
189
|
+
buyer = _subject(ctx)
|
|
190
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
191
|
+
delivery = await verified(app, tx, order)
|
|
192
|
+
require(delivery['delivery_digest'] == request.arguments['delivery_digest'], 'delivery_mismatch')
|
|
193
|
+
require(order['state'] in {'delivered','settled'}, 'delivery_not_acceptable')
|
|
194
|
+
if delivery['state'] == 'claimed':
|
|
195
|
+
return HandlerOutput(data={'order_id': order['id'], 'state': order['state'],
|
|
196
|
+
'delivery_id': delivery['id'], 'receipt': delivery['receipt']})
|
|
197
|
+
settlement = []
|
|
198
|
+
if order['state'] == 'delivered':
|
|
199
|
+
await transition(tx, order, 'accepted', now=ctx.now, actor=buyer,
|
|
200
|
+
request_id=request.request_id, reason='buyer_acceptance')
|
|
201
|
+
settlement = await settle(app, tx, order, now=ctx.now, actor=buyer,
|
|
202
|
+
request_id=request.request_id, reason='buyer_acceptance')
|
|
203
|
+
body = {'order_id': order['id'], 'delivery_id': delivery['id'], 'subject': buyer,
|
|
204
|
+
'delivery_digest': delivery['delivery_digest'], 'request_digest': request.payload_digest,
|
|
205
|
+
'claimed_at': wire(ctx.now)}
|
|
206
|
+
receipt = {'body': body,
|
|
207
|
+
'signature': wire(app.receipt_signer.sign(canonical(body), purpose='delivery-ack'))}
|
|
208
|
+
tx.execute("UPDATE store_deliveries SET state='claimed',claimed_at=?,receipt=? WHERE order_id=?",
|
|
209
|
+
(wire(ctx.now),canonical(receipt).decode(),order['id']), write=True)
|
|
210
|
+
return HandlerOutput(data={'order_id': order['id'], 'state': order['state'],
|
|
211
|
+
'delivery_id': delivery['id'], 'receipt': receipt, 'settlement_receipts': settlement})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def install(app, op):
|
|
215
|
+
@op('delivery.get', ORDER, effect='read', version=2)
|
|
216
|
+
async def get(ctx, request, tx):
|
|
217
|
+
return await read(app, tx, ctx, request)
|
|
218
|
+
|
|
219
|
+
@op('delivery.accept', obj({'order_id': IDENTIFIER, 'delivery_digest': HASH},
|
|
220
|
+
('order_id','delivery_digest')), signature=True, version=2)
|
|
221
|
+
async def claim(ctx, request, tx):
|
|
222
|
+
return await accept(app, tx, ctx, request)
|
|
223
|
+
|
|
224
|
+
@op('delivery.prepare', ORDER, signature=True, version=2)
|
|
225
|
+
async def prepare_managed(ctx, request, tx):
|
|
226
|
+
buyer = _subject(ctx)
|
|
227
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
228
|
+
locked = contract(tx, order['id'])
|
|
229
|
+
require(locked['listing']['delivery_mode'] == 'managed_instant', 'seller_delivery_required')
|
|
230
|
+
if order['state'] == 'funded':
|
|
231
|
+
await automatic(app, tx, ctx, request, order)
|
|
232
|
+
return HandlerOutput(data={'order_id': order['id'], 'state': order['state']})
|
|
233
|
+
|
|
234
|
+
@op('delivery.submit', obj({'order_id': IDENTIFIER, 'payload_ref': REF,
|
|
235
|
+
'payload_digest': HASH, 'recipient_key_id': IDENTIFIER},
|
|
236
|
+
('order_id','payload_ref','payload_digest')), signature=True)
|
|
237
|
+
async def submit(ctx, request, tx):
|
|
238
|
+
from msg.plugins.communication import direct_ancestor
|
|
239
|
+
seller = _subject(ctx)
|
|
240
|
+
order = _row(tx, request.arguments['order_id'], seller)
|
|
241
|
+
require(order['seller'] == seller, 'order_not_found')
|
|
242
|
+
locked = contract(tx, order['id'])
|
|
243
|
+
mode = locked['listing']['delivery_mode']
|
|
244
|
+
require(mode in {'sealed_manual','service'}, 'order_delivery_mode_unsupported')
|
|
245
|
+
require(order['state'] == 'funded', 'order_not_deliverable')
|
|
246
|
+
from datetime import timedelta
|
|
247
|
+
|
|
248
|
+
from msg.core.codec import parse_time
|
|
249
|
+
require(ctx.now < parse_time(order['funded_at']) + timedelta(
|
|
250
|
+
seconds=locked['policy']['policy']['delivery_timeout_seconds']), 'delivery_deadline_passed')
|
|
251
|
+
ref = decode(ResourceRef, request.arguments['payload_ref'])
|
|
252
|
+
require(ref.revision is not None, 'payload_revision_required')
|
|
253
|
+
resource = await tx.resource(ref.id)
|
|
254
|
+
require(resource.owner == seller and resource.type in {'file','attachment'} and
|
|
255
|
+
resource.state == 'active', 'payload_not_owned')
|
|
256
|
+
require(await direct_ancestor(tx, ref.id) is None, 'payload_private_conversation')
|
|
257
|
+
await check_access(app, ctx, request, tx, ref.id, 'read')
|
|
258
|
+
revision = await tx.revision(ref)
|
|
259
|
+
blob = revision.content
|
|
260
|
+
require(blob.digest == request.arguments['payload_digest'], 'delivery_digest_mismatch')
|
|
261
|
+
await verify_blob(app, blob)
|
|
262
|
+
payload = {'id': ref.id, 'revision': ref.revision, 'blob': wire(blob)}
|
|
263
|
+
envelope = None
|
|
264
|
+
if mode == 'sealed_manual':
|
|
265
|
+
validate_target(tx, order, encryption=True)
|
|
266
|
+
require(request.arguments.get('recipient_key_id') == locked['recipient_key']['key_id'],
|
|
267
|
+
'delivery_recipient_mismatch')
|
|
268
|
+
prefix = b''.join([piece async for piece in app.contents.read(blob, (0,min(64,blob.size)))])
|
|
269
|
+
require(blob.media_type in {'application/age','application/octet-stream'} and
|
|
270
|
+
prefix.startswith(b'age-encryption.org/v1\n'), 'encrypted_delivery_required')
|
|
271
|
+
envelope = {'recipient_subject': order['buyer'], 'recipient_key': locked['recipient_key'],
|
|
272
|
+
'ciphertext_ref': payload, 'seller_request_digest': request.payload_digest}
|
|
273
|
+
manifest = {'encryption': 'age-x25519', 'recipient_key_id': locked['recipient_key']['key_id']}
|
|
274
|
+
else:
|
|
275
|
+
require('recipient_key_id' not in request.arguments, 'unexpected_recipient_key')
|
|
276
|
+
manifest = {'type': 'service-evidence', 'acceptance': 'explicit-buyer-signature'}
|
|
277
|
+
delivery = await prepare(app, tx, ctx, request, order, locked['listing']['item_kind'],
|
|
278
|
+
manifest, [payload], envelope=envelope)
|
|
279
|
+
return HandlerOutput(data={'order_id': order['id'], 'delivery_id': delivery['id'],
|
|
280
|
+
'delivery_digest': delivery['delivery_digest'], 'state': 'delivered'})
|
|
281
|
+
|
|
282
|
+
@op('delivery.part_get', obj({'order_id': IDENTIFIER, 'delivery_digest': HASH,
|
|
283
|
+
'payload_index': {'type': 'integer','minimum': 0,'maximum': 31},
|
|
284
|
+
'offset': {'type': 'integer','minimum': 0,'maximum': 1024**4},
|
|
285
|
+
'length': {'type': 'integer','minimum': 1,'maximum': PART}},
|
|
286
|
+
('order_id','delivery_digest','payload_index','offset','length')), effect='read')
|
|
287
|
+
async def part_get(ctx, request, tx):
|
|
288
|
+
a = request.arguments
|
|
289
|
+
order = _buyer_order(tx, a['order_id'], _viewer(ctx))
|
|
290
|
+
delivery = await verified(app, tx, order, verify_bytes=False)
|
|
291
|
+
require(a['delivery_digest'] == delivery['delivery_digest'], 'delivery_digest_mismatch')
|
|
292
|
+
require(a['payload_index'] < len(delivery['payload_refs']), 'payload_not_found')
|
|
293
|
+
blob = decode(BlobRef, delivery['payload_refs'][a['payload_index']]['blob'])
|
|
294
|
+
start, end = a['offset'], a['offset']+a['length']
|
|
295
|
+
require(start < end <= blob.size, 'invalid_byte_range')
|
|
296
|
+
data = b''.join([piece async for piece in app.contents.read(blob, (start,end))])
|
|
297
|
+
require(len(data) == a['length'], 'delivery_digest_mismatch')
|
|
298
|
+
following = ({**a, 'offset': end, 'length': min(PART,blob.size-end)} if end<blob.size else None)
|
|
299
|
+
return HandlerOutput(data={'order_id': order['id'], 'delivery_id': delivery['id'],
|
|
300
|
+
'delivery_digest': delivery['delivery_digest'], 'payload_digest': blob.digest,
|
|
301
|
+
'offset': start, 'size': blob.size, 'data': b64(data), 'chunk_digest': digest(data),
|
|
302
|
+
'next': following})
|
|
303
|
+
|
|
304
|
+
@op('delivery.transfer_open', obj({'order_id':IDENTIFIER,'delivery_digest':HASH,
|
|
305
|
+
'payload_index':{'type':'integer','minimum':0,'maximum':31}},
|
|
306
|
+
('order_id','delivery_digest','payload_index')),signature=True)
|
|
307
|
+
async def transfer_open(ctx, request, tx):
|
|
308
|
+
from datetime import timedelta
|
|
309
|
+
|
|
310
|
+
from msg.core.transfer import TransferService
|
|
311
|
+
from msg.plugins.common import create_resource
|
|
312
|
+
from msg.plugins.transfer import negotiate
|
|
313
|
+
a = request.arguments
|
|
314
|
+
buyer = _subject(ctx)
|
|
315
|
+
order = _buyer_order(tx,a['order_id'],buyer)
|
|
316
|
+
delivery = await verified(app,tx,order)
|
|
317
|
+
require(a['delivery_digest']==delivery['delivery_digest'],'delivery_digest_mismatch')
|
|
318
|
+
require(a['payload_index']<len(delivery['payload_refs']),'payload_not_found')
|
|
319
|
+
blob = decode(BlobRef,delivery['payload_refs'][a['payload_index']]['blob'])
|
|
320
|
+
parent = tx.one("SELECT id FROM resources WHERE parent=? AND name='files'",(buyer,))
|
|
321
|
+
require(parent is not None,'upload_parent_required')
|
|
322
|
+
await check_access(app,ctx,request,tx,parent[0],'create')
|
|
323
|
+
# A purchased copy has independent buyer ACL, but references the exact
|
|
324
|
+
# existing BlobRef. No duplicate payload bytes and no seller ACL bypass
|
|
325
|
+
# through the generic file API. Only this signed operation can publish it.
|
|
326
|
+
resource = await create_resource(app,ctx,request,tx,parent=parent[0],type='file',
|
|
327
|
+
body=blob,media_type=blob.media_type,mode=0o600)
|
|
328
|
+
async def no_upload(*args):
|
|
329
|
+
raise Failure('wrong_transfer_direction')
|
|
330
|
+
limits = negotiate(app,request)
|
|
331
|
+
service = TransferService(app.metadata,app.contents,app.authorizer,app.clock,
|
|
332
|
+
publish=no_upload,ttl=app.settings.transfer_ttl,part_bytes=app.settings.max_part_bytes)
|
|
333
|
+
session = await service.open(ctx,'download',ResourceRef(id=resource.id,revision=resource.revision),
|
|
334
|
+
blob.size,blob.digest,ctx.now+timedelta(seconds=app.settings.transfer_ttl),limits=limits)
|
|
335
|
+
return HandlerOutput(resources=(ResourceRef(id=resource.id,revision=resource.revision),),
|
|
336
|
+
data={'transfer_id':session.id,'order_id':order['id'],'delivery_id':delivery['id'],
|
|
337
|
+
'delivery_digest':delivery['delivery_digest'],'size':blob.size,'digest':blob.digest,
|
|
338
|
+
'state':session.state,'expires_at':wire(session.expires_at),**limits})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Optional, buyer-bound email hints; never delivery payloads or payment work.
|
|
2
|
+
|
|
3
|
+
Facts live in the transactional metadata settings store, not a cache or user
|
|
4
|
+
resource. A signed checkout email is per-order opt-in. An unmatched address stays
|
|
5
|
+
pending until the buyer explicitly verifies it through identity.email_* and
|
|
6
|
+
calls delivery.notify. Checkout never overwrites an existing mailbox.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from msg.core.errors import require
|
|
13
|
+
from msg.core.models import EffectJob
|
|
14
|
+
from msg.market.delivery_targets import (
|
|
15
|
+
email_binding, mail_enabled, pickup_url, validate_email_binding, validate_target,
|
|
16
|
+
)
|
|
17
|
+
from msg.plugins.common import new_id
|
|
18
|
+
from msg.plugins.communication import event_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _key(order_id):
|
|
22
|
+
return 'delivery_notification:' + order_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fact(tx, order):
|
|
26
|
+
fact = tx.setting(_key(order['id']))
|
|
27
|
+
require(fact is not None and fact.get('version') == 1 and
|
|
28
|
+
fact.get('order_id') == order['id'] and fact.get('buyer') == order['buyer'],
|
|
29
|
+
'delivery_notification_unavailable')
|
|
30
|
+
validate_target(order)
|
|
31
|
+
return dict(fact)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def notification_status(app, tx, order_id):
|
|
35
|
+
fact = tx.setting(_key(order_id))
|
|
36
|
+
if not fact:
|
|
37
|
+
return {'state': 'disabled'}
|
|
38
|
+
if fact['job_id']:
|
|
39
|
+
job = await tx.job(fact['job_id'])
|
|
40
|
+
state = {'pending': 'queued', 'running': 'queued', 'done': 'smtp_accepted',
|
|
41
|
+
'uncertain': 'delivered_unknown', 'failed': 'failed'}[job.state]
|
|
42
|
+
if job.state in {'pending', 'running'} and (not fact['enabled'] or not mail_enabled(app)):
|
|
43
|
+
state = 'disabled'
|
|
44
|
+
return {'state': state}
|
|
45
|
+
return {'state': 'pending' if fact['enabled'] and mail_enabled(app) else 'disabled'}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def initialize_notification(app, ctx, request, tx, order, address):
|
|
49
|
+
if address is None:
|
|
50
|
+
return {'state': 'disabled'}
|
|
51
|
+
validate_target(order)
|
|
52
|
+
handle = (await tx.resource(order['buyer'])).name
|
|
53
|
+
fact = {'version': 1, 'order_id': order['id'], 'buyer': order['buyer'],
|
|
54
|
+
'buyer_handle': handle, 'address': address, 'enabled': True,
|
|
55
|
+
'endpoint': email_binding(tx, order['buyer'], address), 'job_id': None}
|
|
56
|
+
tx.set_setting(_key(order['id']), fact)
|
|
57
|
+
return await queue_notification(app, ctx, request, tx, order, enabled=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def queue_notification(app, ctx, request, tx, order, *, enabled):
|
|
61
|
+
fact = _fact(tx, order)
|
|
62
|
+
require(ctx.principal.actor == ctx.principal.subject == order['buyer'], 'order_not_found')
|
|
63
|
+
require(order['state'] in {'delivered', 'settled'}, 'order_not_deliverable')
|
|
64
|
+
fact['enabled'] = enabled
|
|
65
|
+
tx.set_setting(_key(order['id']), fact)
|
|
66
|
+
if not enabled or not mail_enabled(app) or fact['job_id'] is not None:
|
|
67
|
+
# A failed/uncertain external attempt is never revived by another request.
|
|
68
|
+
return await notification_status(app, tx, order['id'])
|
|
69
|
+
if fact['endpoint'] is None:
|
|
70
|
+
fact['endpoint'] = email_binding(tx, order['buyer'], fact['address'])
|
|
71
|
+
if fact['endpoint'] is None:
|
|
72
|
+
return {'state': 'pending'}
|
|
73
|
+
validate_email_binding(tx, order, fact['endpoint'])
|
|
74
|
+
job_id = new_id('job')
|
|
75
|
+
fact['job_id'] = job_id
|
|
76
|
+
tx.set_setting(_key(order['id']), fact)
|
|
77
|
+
await tx.enqueue(EffectJob(id=job_id, event_id=event_id(request, order['buyer']),
|
|
78
|
+
kind='mail', dedupe_key='order:' + order['id'] + ':' + order['buyer'] + ':mail',
|
|
79
|
+
principal=ctx.principal, operation=request.operation,
|
|
80
|
+
arguments={'order_id': order['id'], 'recipient_subject': order['buyer'],
|
|
81
|
+
'contract_version': request.contract_version, 'order_notification': True},
|
|
82
|
+
state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None))
|
|
83
|
+
return {'state': 'queued'}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def project_notification(app, tx, job, principal):
|
|
87
|
+
"""Reconstruct the allowlisted message only after every live owner check."""
|
|
88
|
+
from msg.plugins.orders import _row
|
|
89
|
+
from msg.plugins.delivery import _delivery, _verified_delivery
|
|
90
|
+
require(mail_enabled(app), 'mail_disabled')
|
|
91
|
+
require((job.operation, job.arguments.get('contract_version')) in {
|
|
92
|
+
('orders.buy', 2), ('delivery.notify', 1)}, 'invalid_delivery_notification')
|
|
93
|
+
buyer = job.arguments['recipient_subject']
|
|
94
|
+
require(principal.actor == principal.subject == buyer, 'delivery_recipient_mismatch')
|
|
95
|
+
await app.authorizer.require_base(principal,
|
|
96
|
+
f"{job.operation}@{job.arguments['contract_version']}", buyer, tx)
|
|
97
|
+
order = _row(tx, job.arguments['order_id'], buyer)
|
|
98
|
+
require(order['buyer'] == buyer and order['state'] in {'delivered', 'settled'},
|
|
99
|
+
'delivery_recipient_mismatch')
|
|
100
|
+
fact = _fact(tx, order)
|
|
101
|
+
require(fact['job_id'] == job.id and fact['enabled'], 'notification_disabled')
|
|
102
|
+
delivery = _delivery(tx, order['id'])
|
|
103
|
+
await _verified_delivery(app, tx, order, delivery)
|
|
104
|
+
recipient = validate_email_binding(tx, order, fact['endpoint'])
|
|
105
|
+
require(fact['address'] == recipient, 'delivery_recipient_mismatch')
|
|
106
|
+
link = pickup_url(app, order['id'])
|
|
107
|
+
text = order['id'] + '\n' + fact['buyer_handle'] + '\n' + link + '\n'
|
|
108
|
+
# Never forward a caller/job-selected subject, text, attachment or token.
|
|
109
|
+
return replace(job, arguments={'recipient': recipient, 'recipient_subject': buyer,
|
|
110
|
+
'subject': 'msg order ready', 'text': text})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""One current buyer binding for site delivery and optional email projections."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import decode, loads, wire
|
|
7
|
+
from msg.core.errors import Failure, require
|
|
8
|
+
from msg.core.email_address import validate_address
|
|
9
|
+
from msg.core.models import EmailSettings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mail_enabled(app):
|
|
13
|
+
config = app.settings.server.mail
|
|
14
|
+
return config is not None and config.enabled
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pickup_url(app, order_id):
|
|
18
|
+
"""Build a non-capability link without leaking credentials from configuration."""
|
|
19
|
+
origin = app.settings.service_url
|
|
20
|
+
require(isinstance(origin, str) and
|
|
21
|
+
all(ord(c) > 32 and ord(c) != 127 for c in origin) and
|
|
22
|
+
'\\' not in origin, 'invalid_delivery_origin')
|
|
23
|
+
try:
|
|
24
|
+
base = urlsplit(origin)
|
|
25
|
+
valid = (base.scheme in {'http', 'https'} and base.hostname and
|
|
26
|
+
base.username is None and base.password is None and
|
|
27
|
+
not base.query and not base.fragment)
|
|
28
|
+
base.port # Validate malformed/out-of-range ports as well as IPv6 syntax.
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise Failure('invalid_delivery_origin') from None
|
|
31
|
+
require(valid, 'invalid_delivery_origin')
|
|
32
|
+
return origin.rstrip('/') + '/_orders/' + order_id + '/_delivery'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def account_email(tx, buyer):
|
|
36
|
+
"""Read the live account row once; endpoint encodings remain version-specific."""
|
|
37
|
+
row = tx.one('SELECT generation,body FROM emails WHERE subject=?', (buyer,))
|
|
38
|
+
if row is None:
|
|
39
|
+
return None, None
|
|
40
|
+
email = decode(EmailSettings, loads(row[1]))
|
|
41
|
+
require(email.subject_id == buyer, 'delivery_recipient_mismatch')
|
|
42
|
+
return row[0], email
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_target(order, delivery=None):
|
|
46
|
+
require(order['delivery_target'] == {
|
|
47
|
+
'subject_id': order['buyer'], 'channel': 'site'},
|
|
48
|
+
'delivery_recipient_mismatch')
|
|
49
|
+
if delivery is not None:
|
|
50
|
+
require(delivery['order_id'] == order['id'] and
|
|
51
|
+
delivery['recipient_subject'] == order['buyer'] and
|
|
52
|
+
delivery['channel'] == 'site', 'delivery_recipient_mismatch')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def email_binding(tx, buyer, address):
|
|
56
|
+
"""Unverified/unmatched addresses are pending, never an authorized target.
|
|
57
|
+
|
|
58
|
+
The existing single-email row is the endpoint. Its generation also fences
|
|
59
|
+
revoke/reverify and address reuse; equality of the address alone is unsafe.
|
|
60
|
+
"""
|
|
61
|
+
generation, email = account_email(tx, buyer)
|
|
62
|
+
if email is None or email.address != address or email.verified_at is None:
|
|
63
|
+
return None
|
|
64
|
+
validate_address(email.address)
|
|
65
|
+
return {'endpoint_id': 'email:' + buyer, 'owner': buyer,
|
|
66
|
+
'generation': generation, 'address_snapshot': address,
|
|
67
|
+
'verified_at': wire(email.verified_at)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_email_binding(tx, order, binding):
|
|
71
|
+
validate_target(order)
|
|
72
|
+
require(isinstance(binding, dict) and binding.get('owner') == order['buyer'],
|
|
73
|
+
'delivery_recipient_mismatch')
|
|
74
|
+
current = email_binding(tx, order['buyer'], binding.get('address_snapshot'))
|
|
75
|
+
require(current is not None and current == binding, 'delivery_recipient_mismatch')
|
|
76
|
+
return current['address_snapshot']
|
msg/market/email.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Optional checkout-address verification, separate from account email settings.
|
|
2
|
+
|
|
3
|
+
The emailed proof cannot authenticate or identify an order. Verification also
|
|
4
|
+
requires the original buyer's current signed request. An endpoint never changes
|
|
5
|
+
owner; a disabled endpoint is not reactivated by retries or a restored job.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import secrets
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
|
|
13
|
+
from msg.core.codec import b64, canonical, digest, parse_time, wire
|
|
14
|
+
from msg.core.errors import require
|
|
15
|
+
from msg.core.models import EffectJob, HandlerOutput
|
|
16
|
+
from msg.market.targets import enqueue_notification, mail_enabled, save_target, validate_target
|
|
17
|
+
from msg.plugins.common import new_id
|
|
18
|
+
from msg.plugins.orders import _row, _subject
|
|
19
|
+
from msg.plugins.schemas import IDENTIFIER, obj
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def verification_message(app, tx, ctx, request, order):
|
|
23
|
+
chosen = order['delivery_target'].get('email', {})
|
|
24
|
+
if (not mail_enabled(app) or chosen.get('endpoint_id') or
|
|
25
|
+
not chosen.get('address_snapshot')):
|
|
26
|
+
return
|
|
27
|
+
token, challenge_id = b64(secrets.token_bytes(32)), new_id('evc')
|
|
28
|
+
tx.execute('''INSERT INTO delivery_email_challenges
|
|
29
|
+
(id,order_id,subject,address,verifier,expires_at) VALUES (?,?,?,?,?,?)''',
|
|
30
|
+
(challenge_id,order['id'],order['buyer'],chosen['address_snapshot'],digest(token),
|
|
31
|
+
wire(ctx.now+timedelta(minutes=15))), write=True)
|
|
32
|
+
save_target(tx, order, {**order['delivery_target'], 'email': {
|
|
33
|
+
**chosen, 'challenge_id':challenge_id, 'state':'pending'}})
|
|
34
|
+
await tx.enqueue(EffectJob(id=new_id('job'),event_id=challenge_id,kind='market_email_verify',
|
|
35
|
+
dedupe_key='market-verify:'+challenge_id,principal=ctx.principal,
|
|
36
|
+
operation=request.operation,arguments={'challenge_id':challenge_id,'token':token},
|
|
37
|
+
state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def render_verification(app, tx, job):
|
|
41
|
+
row = tx.one('''SELECT order_id,subject,address,verifier,expires_at,consumed
|
|
42
|
+
FROM delivery_email_challenges WHERE id=?''', (job.arguments['challenge_id'],))
|
|
43
|
+
require(row is not None and not row[5] and parse_time(row[4])>app.clock() and
|
|
44
|
+
row[1] == job.principal.subject == job.principal.actor and
|
|
45
|
+
digest(job.arguments['token']) == row[3], 'email_challenge_expired')
|
|
46
|
+
order = _row(tx,row[0],row[1])
|
|
47
|
+
chosen = order['delivery_target'].get('email',{})
|
|
48
|
+
require(order['buyer']==row[1] and chosen.get('challenge_id')==job.arguments['challenge_id'] and
|
|
49
|
+
chosen.get('address_snapshot')==row[2] and chosen.get('endpoint_id') is None and
|
|
50
|
+
order['state'] in {'created','funded','delivered','settled'}, 'email_challenge_expired')
|
|
51
|
+
# No order ID, buyer handle, goods, pickup link or authority in this text.
|
|
52
|
+
return replace(job,kind='mail',arguments={'recipient':row[2],
|
|
53
|
+
'recipient_subject':row[1], 'subject':'msg address verification',
|
|
54
|
+
'text':canonical({'challenge_id':job.arguments['challenge_id'],
|
|
55
|
+
'token':job.arguments['token'], 'purpose':'verify this address only'}).decode()})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install(app, op):
|
|
59
|
+
from msg.market.orders import ORDER, view
|
|
60
|
+
|
|
61
|
+
@op('orders.email_verify',obj({'order_id':IDENTIFIER,'challenge_id':IDENTIFIER,
|
|
62
|
+
'token':{'type':'string','minLength':43,'maxLength':43}},
|
|
63
|
+
('order_id','challenge_id','token')),signature=True)
|
|
64
|
+
async def verify(ctx, request, tx):
|
|
65
|
+
a, buyer = request.arguments, _subject(ctx)
|
|
66
|
+
order = _row(tx,a['order_id'],buyer)
|
|
67
|
+
require(order['buyer']==buyer,'order_not_found')
|
|
68
|
+
row = tx.one('''SELECT order_id,subject,address,verifier,expires_at,consumed
|
|
69
|
+
FROM delivery_email_challenges WHERE id=?''',(a['challenge_id'],))
|
|
70
|
+
chosen = order['delivery_target'].get('email',{})
|
|
71
|
+
require(row is not None and row[0]==order['id'] and row[1]==buyer and not row[5] and
|
|
72
|
+
parse_time(row[4])>ctx.now and secrets.compare_digest(row[3],digest(a['token'])) and
|
|
73
|
+
chosen.get('challenge_id')==a['challenge_id'] and
|
|
74
|
+
chosen.get('address_snapshot')==row[2] and chosen.get('endpoint_id') is None and
|
|
75
|
+
order['state'] in {'created','funded','delivered','settled'},'email_challenge_invalid')
|
|
76
|
+
endpoint = new_id('dep')
|
|
77
|
+
tx.execute('INSERT INTO delivery_email_endpoints(id,subject,address,verified_at) VALUES (?,?,?,?)',
|
|
78
|
+
(endpoint,buyer,row[2],wire(ctx.now)),write=True)
|
|
79
|
+
tx.execute('UPDATE delivery_email_challenges SET consumed=TRUE WHERE id=?',
|
|
80
|
+
(a['challenge_id'],),write=True)
|
|
81
|
+
chosen = {k:v for k,v in chosen.items() if k!='challenge_id'}
|
|
82
|
+
save_target(tx,order,{**order['delivery_target'],'email':{
|
|
83
|
+
**chosen,'endpoint_id':endpoint,'state':'pending'}})
|
|
84
|
+
validate_target(tx,order,email=True)
|
|
85
|
+
if order['delivered_at']:
|
|
86
|
+
await enqueue_notification(app,tx,ctx,request,order)
|
|
87
|
+
return HandlerOutput(data={'order':view(tx,order,buyer)})
|
|
88
|
+
|
|
89
|
+
@op('orders.email_disable',ORDER,signature=True)
|
|
90
|
+
async def disable(ctx, request, tx):
|
|
91
|
+
buyer = _subject(ctx)
|
|
92
|
+
order = _row(tx,request.arguments['order_id'],buyer)
|
|
93
|
+
require(order['buyer']==buyer,'order_not_found')
|
|
94
|
+
chosen = order['delivery_target'].get('email',{})
|
|
95
|
+
if (chosen.get('endpoint_id') or '').startswith('dep_'):
|
|
96
|
+
tx.execute('UPDATE delivery_email_endpoints SET active=FALSE WHERE id=? AND subject=?',
|
|
97
|
+
(chosen['endpoint_id'],buyer),write=True)
|
|
98
|
+
save_target(tx,order,{**order['delivery_target'],'email':{
|
|
99
|
+
**chosen,'state':'disabled','notification_disabled':True}})
|
|
100
|
+
return HandlerOutput(data={'order':view(tx,order,buyer)})
|