msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
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)})