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/escrow.py ADDED
@@ -0,0 +1,345 @@
1
+ """One escrow release entry point, guarded transitions and append-only facts."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import timedelta
5
+
6
+ from msg.core.codec import b64, canonical, decode, digest, freeze_json, loads, parse_time, wire
7
+ from msg.core.errors import require
8
+ from msg.core.models import Event, Signature
9
+ from msg.core.requests import signing_bytes
10
+ from msg.market.policy import contract, delivery_snapshot
11
+ from msg.plugins.common import new_id
12
+ from msg.plugins.money import CURRENCY_ID, _balance, _post_transfer
13
+ from msg.security.crypto import verify
14
+
15
+ # Not a credential, account or public capability. Only trusted in-process market
16
+ # code can reach this token; ordinary signed money.transfer never receives it.
17
+ _ESCROW_WRITE = object()
18
+ TRANSITIONS = {
19
+ 'created': {'funded', 'cancelled'},
20
+ 'funded': {'delivered', 'refunded', 'disputed'},
21
+ 'delivered': {'accepted', 'refunded', 'disputed'},
22
+ 'accepted': {'settled'},
23
+ 'disputed': {'refunded', 'settled'},
24
+ 'cancelled': set(), 'refunded': set(), 'settled': set(),
25
+ }
26
+ OBJECTIVE_REFUNDS = frozenset({'package_missing', 'package_digest_mismatch',
27
+ 'delivery_missing', 'delivery_digest_mismatch', 'delivery_timeout', 'buyer_cancelled'})
28
+
29
+
30
+ def _post_escrow_transfer(tx, *, order, recipient, amount, actor, request_id, now,
31
+ receipt_signer, reference, kind):
32
+ """The only place that supplies internal authority to debit OrderEscrow.
33
+
34
+ Legacy signed decisions and versioned arbitration keep their distinct
35
+ journals, signatures and request IDs, but share this account boundary.
36
+ """
37
+ account = tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?',
38
+ (order['escrow_subject'],))
39
+ require(account == ('order_escrow', None, order['id']), 'escrow_account_mismatch')
40
+ require(recipient in {order['buyer'], order['seller']}, 'escrow_recipient_mismatch')
41
+ return _post_transfer(tx, sender=order['escrow_subject'], recipient=recipient,
42
+ amount=amount, actor=actor, request_id=request_id, now=now,
43
+ receipt_signer=receipt_signer, reference=reference, kind=kind,
44
+ escrow_authority=_ESCROW_WRITE)
45
+
46
+
47
+ async def transition(tx, order, state, *, now, actor, request_id, reason):
48
+ require(state in TRANSITIONS.get(order['state'], ()), 'order_transition_invalid')
49
+ changed = tx.execute('UPDATE store_orders SET state=? WHERE id=? AND state=?',
50
+ (state, order['id'], order['state']), write=True)
51
+ require(changed.rowcount == 1, 'order_transition_conflict')
52
+ fact = {'order_id': order['id'], 'from': order['state'], 'to': state,
53
+ 'at': wire(now), 'reason': reason, 'policy': order['dispute_policy'],
54
+ 'actor': actor, 'request_id': request_id}
55
+ tx.execute('INSERT INTO order_transitions(id,order_id,body) VALUES (?,?,?)',
56
+ (new_id('ot'), order['id'], canonical(fact).decode()), write=True)
57
+ # Events deliberately contain no buyer handle, evidence, goods or address.
58
+ await tx.append_event(Event(id=new_id('ev'), type='orders.' + state, time=now,
59
+ actor=actor, subject=order['buyer'], request_id=request_id,
60
+ resources=(), data={'order_id': order['id'], 'state': state}))
61
+ order['state'] = state
62
+
63
+
64
+ async def settle(app, tx, order, *, now, actor, request_id, reason,
65
+ refund_minor=0, decision=None):
66
+ """Validate authority *before* appending either leg of a balanced settlement.
67
+
68
+ Caller owns the transaction. Any failure rolls back both legs, the decision
69
+ consumption, transitions, receipts, events and request result together.
70
+ """
71
+ contract(tx, order['id'])
72
+ require(tx.one('SELECT 1 FROM order_escrow_decisions WHERE order_id=?', (order['id'],)) is None,
73
+ 'order_already_settled')
74
+ total = order['total_price_minor']
75
+ require(type(refund_minor) is int and 0 <= refund_minor <= total,
76
+ 'settlement_amount_invalid')
77
+ existing = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],))
78
+ if existing:
79
+ body = loads(existing[0])
80
+ require(body['refund_minor'] == refund_minor and body['reason'] == reason and
81
+ body['decision_id'] == (decision['id'] if decision else None),
82
+ 'order_already_settled')
83
+ return body['receipts']
84
+ if decision is not None:
85
+ from msg.market.arbitration import validate_decision
86
+ await validate_decision(app, tx, order, decision, now)
87
+ require(refund_minor == decision['refund_minor'], 'decision_amount_mismatch')
88
+ elif refund_minor == total:
89
+ require(reason in OBJECTIVE_REFUNDS and order['state'] in {'funded', 'delivered'},
90
+ 'escrow_release_forbidden')
91
+ else:
92
+ require(refund_minor == 0 and order['state'] == 'accepted' and
93
+ reason in {'buyer_acceptance', 'managed_instant_verified'},
94
+ 'escrow_release_forbidden')
95
+ require(_balance(tx, order['escrow_subject']) == total, 'escrow_balance_mismatch')
96
+ receipts = []
97
+ for suffix, recipient, amount, kind in (
98
+ ('refund', order['buyer'], refund_minor, 'refund'),
99
+ ('release', order['seller'], total-refund_minor, 'transfer'),
100
+ ):
101
+ if amount:
102
+ receipts.append(_post_escrow_transfer(tx, order=order,
103
+ recipient=recipient, amount=amount, actor=actor,
104
+ request_id=f'{order["id"]}:{suffix}', now=now,
105
+ receipt_signer=app.receipt_signer, reference=f'order_{suffix}:{order["id"]}',
106
+ kind=kind))
107
+ final = 'refunded' if refund_minor == total else 'settled'
108
+ await transition(tx, order, final, now=now, actor=actor, request_id=request_id, reason=reason)
109
+ refs = order['receipt_refs'] + [r['body']['transaction_id'] for r in receipts]
110
+ tx.execute('UPDATE store_orders SET settled_at=?,receipt_refs=? WHERE id=?',
111
+ (wire(now), canonical(refs).decode(), order['id']), write=True)
112
+ statement = {'order_id': order['id'], 'refund_minor': refund_minor,
113
+ 'release_minor': total-refund_minor, 'reason': reason,
114
+ 'decision_id': decision['id'] if decision else None,
115
+ 'policy': order['dispute_policy'], 'receipts': receipts, 'at': wire(now),
116
+ 'order_facts': {name: order[name] for name in ('payment_transaction_id',
117
+ 'payment_intent_digest', 'funded_at', 'delivered_at')},
118
+ 'delivery_snapshot': delivery_snapshot(tx, order['id'])}
119
+ tx.execute('INSERT INTO order_settlements(order_id,decision_id,body) VALUES (?,?,?)',
120
+ (order['id'], statement['decision_id'], canonical(statement).decode()), write=True)
121
+ if decision:
122
+ tx.execute("UPDATE arbitration_cases SET state='executed' WHERE id=? AND state='decided'",
123
+ (decision['case_id'],), write=True)
124
+ order.update(receipt_refs=refs, settled_at=wire(now))
125
+ return receipts
126
+
127
+
128
+ async def resolve_due(app, *, limit=100):
129
+ """Bounded worker tick, never called by a GET or by the ClearingEngine to buy.
130
+
131
+ Time starts at persisted creation/funding, not worker retries. Undelivered
132
+ services/manual ciphertext refund; subjective complaints and expired panels
133
+ remain held. This function has no external side effect.
134
+ """
135
+ from msg.plugins.orders import _row
136
+ now = app.clock()
137
+ changed = []
138
+ async with app.metadata.transaction(write=True) as tx:
139
+ rows = tx.rows('''SELECT o.id,o.buyer FROM store_orders o
140
+ JOIN order_deadlines d ON d.order_id=o.id
141
+ WHERE o.state IN ('created','funded') AND d.expires_at<=?
142
+ ORDER BY d.expires_at,o.id LIMIT ?''', (wire(now),limit))
143
+ for order_id, buyer in rows:
144
+ order = _row(tx, order_id, buyer)
145
+ terms = contract(tx, order_id)
146
+ policy = terms['policy']['policy']
147
+ origin, seconds = ((order['created_at'], policy['funding_timeout_seconds'])
148
+ if order['state'] == 'created' else
149
+ (order['funded_at'], policy['delivery_timeout_seconds']))
150
+ if now < parse_time(origin) + timedelta(seconds=seconds):
151
+ continue
152
+ if order['state'] == 'created':
153
+ await transition(tx, order, 'cancelled', now=now, actor=buyer,
154
+ request_id='timeout:'+order_id, reason='funding_timeout')
155
+ else:
156
+ await settle(app, tx, order, now=now, actor=buyer,
157
+ request_id='timeout:'+order_id, reason='delivery_timeout',
158
+ refund_minor=order['total_price_minor'])
159
+ changed.append(order_id)
160
+ return changed
161
+
162
+
163
+ # Published checkout contracts retain their original signed policy and journals.
164
+ POLICY = freeze_json({
165
+ 'escrow_policy': 'escrow-v1', 'dispute_policy': 'dispute-v1',
166
+ 'version': 1, 'reasons': ['buyer_cancel', 'buyer_accept'],
167
+ 'arbitration_enabled': False, 'automatic_timeout_enabled': False,
168
+ 'allow_split': False,
169
+ })
170
+ POLICY_DIGEST = digest(POLICY)
171
+ # A new policy ID, not a silent expansion of already signed escrow-v1 terms.
172
+ INSTANT_POLICY = freeze_json({
173
+ **POLICY, 'escrow_policy': 'escrow-instant-v1',
174
+ 'reasons': ['buyer_cancel', 'buyer_accept', 'checkout_accept'],
175
+ 'checkout_contract': 'orders.buy@2', 'requires_package_digest': True,
176
+ 'checkout_claims_delivery': False,
177
+ })
178
+ POLICIES = (POLICY, INSTANT_POLICY)
179
+
180
+
181
+ def validate_policy(escrow_policy, dispute_policy):
182
+ for policy in POLICIES:
183
+ if (escrow_policy, dispute_policy) == (policy['escrow_policy'], policy['dispute_policy']):
184
+ return policy
185
+ require(False, 'escrow_policy_unsupported')
186
+
187
+
188
+ def validate_decision(decision, expected, public_key, now):
189
+ """A signature never substitutes for current deterministic policy checks."""
190
+ require(set(decision)=={'body','signature'} and decision['body']==expected,
191
+ 'escrow_decision_mismatch')
192
+ require(parse_time(expected['expires_at'])>now, 'escrow_decision_expired')
193
+ verify(public_key,canonical(expected),decode(Signature,decision['signature']),
194
+ purpose='escrow-decision-v1')
195
+
196
+
197
+ class EscrowEngine:
198
+ def __init__(self, app):
199
+ self.app=app
200
+
201
+ async def settle(self, ctx, request, tx, *, reason, order_id=None):
202
+ # Import handlers' read-only projections, never their write entry points.
203
+ from msg.plugins.delivery import _delivery, _verified_delivery
204
+ from msg.plugins.orders import _row, _subject
205
+ buyer=_subject(ctx)
206
+ require(order_id is None or reason == 'checkout_accept', 'escrow_decision_mismatch')
207
+ order=_row(tx,order_id or request.arguments['order_id'],buyer)
208
+ require(tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (order['id'],)) is None,
209
+ 'order_contract_requires_v3')
210
+ require(tx.one('SELECT 1 FROM order_settlements WHERE order_id=?', (order['id'],)) is None,
211
+ 'order_already_resolved')
212
+ require(order['buyer']==buyer, 'order_not_found')
213
+ policy=validate_policy(order['escrow_policy'],order['dispute_policy'])
214
+ require(reason in policy['reasons'], 'escrow_reason_unsupported')
215
+ await self.app.authorizer.require_base(ctx.principal,
216
+ f'{request.operation}@{request.contract_version}',buyer,tx)
217
+ delivery=_delivery(tx,order['id'])
218
+ if reason=='buyer_cancel':
219
+ require(request.operation=='orders.cancel' and request.contract_version==1,
220
+ 'escrow_decision_mismatch')
221
+ require(order['state']=='funded' and order['delivered_at'] is None and
222
+ delivery is None, 'order_not_cancellable')
223
+ recipient,outcome,state=buyer,'refund','refunded'
224
+ elif reason in {'buyer_accept', 'checkout_accept'}:
225
+ checkout = reason == 'checkout_accept'
226
+ if checkout:
227
+ require(request.operation == 'orders.buy' and request.contract_version == 2 and
228
+ request.arguments.get('auto_accept') is True and
229
+ request.arguments['package_digest'] == order['package_digest'] and
230
+ order['payment_intent_digest'] == request.payload_digest,
231
+ 'escrow_decision_mismatch')
232
+ else:
233
+ require(request.operation=='delivery.accept' and request.contract_version==1,
234
+ 'escrow_decision_mismatch')
235
+ await _verified_delivery(self.app,tx,order,delivery)
236
+ if not checkout:
237
+ require(delivery['delivery_digest']==request.arguments['delivery_digest'],
238
+ 'delivery_mismatch')
239
+ require(order['state']=='delivered' and order['delivered_at'] is not None and
240
+ delivery['state']=='prepared', 'delivery_not_acceptable')
241
+ require(order['delivery_target']=={'subject_id':buyer,'channel':'site'},
242
+ 'delivery_recipient_mismatch')
243
+ recipient,outcome,state=order['seller'],'release','settled'
244
+ else:
245
+ require(False, 'escrow_reason_unsupported')
246
+ require(order['currency_id']==CURRENCY_ID and
247
+ _balance(tx,order['escrow_subject'])==order['total_price_minor'],
248
+ 'escrow_balance_mismatch')
249
+ account=tx.one('SELECT kind,subject_id,source_id FROM ledger_accounts WHERE id=?',
250
+ (order['escrow_subject'],))
251
+ require(account==('order_escrow',None,order['id']), 'escrow_account_mismatch')
252
+ require(tx.one('SELECT 1 FROM order_escrow_decisions WHERE order_id=?',
253
+ (order['id'],)) is None, 'order_already_resolved')
254
+ # The ledger retains one row per (actor, request_id). Checkout has two
255
+ # legs, so its release uses a domain-separated internal ID, explicitly
256
+ # linked to the original signed request by the immutable decision.
257
+ ledger_request_id = (digest(('checkout-settlement-v1', buyer, request.request_id, order['id']))
258
+ if reason == 'checkout_accept' else request.request_id)
259
+ body={'id':'ed_'+digest((order['id'],request.payload_digest,reason))[7:],
260
+ 'order_id':order['id'],'outcome':outcome,'reason':reason,
261
+ 'recipient':recipient,'currency_id':CURRENCY_ID,
262
+ 'amount_minor':order['total_price_minor'],
263
+ 'policy_version':policy['version'],'policy_digest':digest(policy),
264
+ 'order_digest':digest(order),'evidence_digest':digest(delivery),
265
+ 'request_id':request.request_id,'request_digest':request.payload_digest,
266
+ 'actor':buyer,'issued_at':wire(ctx.now),'expires_at':wire(request.expires_at)}
267
+ if reason == 'checkout_accept':
268
+ body['ledger_request_id'] = ledger_request_id
269
+ decision={'body':body,'signature':wire(self.app.receipt_signer.sign(
270
+ canonical(body),purpose='escrow-decision-v1'))}
271
+ # The current policy, source request, order and delivery are all checked
272
+ # under the same serialized write transaction as the debit and journal.
273
+ validate_decision(decision,body,self.app.receipt_signer.public_key,
274
+ self.app.executor.clock())
275
+ receipt=_post_escrow_transfer(tx,order=order,recipient=recipient,
276
+ amount=order['total_price_minor'],actor=buyer,request_id=ledger_request_id,
277
+ now=ctx.now,receipt_signer=self.app.receipt_signer,
278
+ reference='order_'+outcome+':'+order['id'],
279
+ kind='refund' if outcome=='refund' else 'transfer')
280
+ transaction_id=receipt['body']['transaction_id']
281
+ if reason == 'checkout_accept':
282
+ changed=tx.execute("""UPDATE store_deliveries SET receipt=?
283
+ WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
284
+ (canonical(receipt).decode(),order['id'],buyer),write=True)
285
+ require(changed.rowcount==1, 'delivery_not_acceptable')
286
+ elif outcome=='release':
287
+ changed=tx.execute("""UPDATE store_deliveries
288
+ SET state='claimed',claimed_at=?,receipt=?
289
+ WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
290
+ (wire(ctx.now),canonical(receipt).decode(),order['id'],buyer),write=True)
291
+ require(changed.rowcount==1, 'delivery_not_acceptable')
292
+ changed=tx.execute('''UPDATE store_orders SET state=?,settled_at=?,receipt_refs=?
293
+ WHERE id=? AND buyer=? AND state=?''',
294
+ (state,wire(ctx.now) if outcome=='release' else order['settled_at'],
295
+ canonical([*order['receipt_refs'],transaction_id]).decode(),
296
+ order['id'],buyer,order['state']),write=True)
297
+ require(changed.rowcount==1, 'escrow_state_conflict')
298
+ tx.execute('''INSERT INTO order_escrow_decisions
299
+ (order_id,id,body,signature,source_proof,transaction_id) VALUES (?,?,?,?,?,?)''',
300
+ (order['id'],body['id'],canonical(body).decode(),canonical(decision['signature']).decode(),
301
+ canonical({'signed_envelope':b64(signing_bytes(request)),
302
+ 'proof':wire(request.proof)}).decode(),transaction_id),write=True)
303
+ return receipt,decision,delivery
304
+
305
+ async def verify_checkout_release(self, tx, order, delivery):
306
+ """Validate an already committed release, without extending its execution TTL."""
307
+ require(order['state'] == 'settled' and order['settled_at'] is not None and
308
+ delivery['receipt'] is not None, 'delivery_not_acceptable')
309
+ policy = validate_policy(order['escrow_policy'], order['dispute_policy'])
310
+ require(policy == INSTANT_POLICY, 'delivery_not_acceptable')
311
+ row = tx.one('''SELECT body,signature,transaction_id FROM order_escrow_decisions
312
+ WHERE order_id=?''', (order['id'],))
313
+ require(row is not None, 'escrow_decision_mismatch')
314
+ body = loads(row[0])
315
+ require(body['order_id'] == order['id'] and body['reason'] == 'checkout_accept' and
316
+ body['outcome'] == 'release' and body['actor'] == order['buyer'] and
317
+ body['recipient'] == order['seller'] and body['currency_id'] == CURRENCY_ID and
318
+ body['amount_minor'] == order['total_price_minor'] and
319
+ body['policy_version'] == policy['version'] and body['policy_digest'] == digest(policy) and
320
+ body['request_digest'] == order['payment_intent_digest'] and
321
+ order['receipt_refs'] == [order['payment_transaction_id'], row[2]] and
322
+ order['settled_at'] == body['issued_at'], 'escrow_decision_mismatch')
323
+ # Reconstruct only the fields changed by the original atomic release.
324
+ # Mutable projections after restoration must still match its signed
325
+ # order and delivery snapshots, not merely buyer/amount/order ID.
326
+ issued_order = {**order, 'state': 'delivered', 'settled_at': None,
327
+ 'receipt_refs': [order['payment_transaction_id']]}
328
+ issued_delivery = {**delivery, 'state': 'prepared', 'claimed_at': None, 'receipt': None}
329
+ ledger_request_id = digest(('checkout-settlement-v1', order['buyer'],
330
+ body['request_id'], order['id']))
331
+ require(body['order_digest'] == digest(issued_order) and
332
+ body['evidence_digest'] == digest(issued_delivery) and
333
+ body['ledger_request_id'] == ledger_request_id, 'escrow_decision_mismatch')
334
+ # The decision's deadline limited execution, not how long the buyer may
335
+ # acknowledge a previously paid order. Verify its original signed fact.
336
+ validate_decision({'body': body, 'signature': loads(row[1])}, body,
337
+ self.app.receipt_signer.public_key, parse_time(body['issued_at']))
338
+ ledger = tx.one('''SELECT debit_account,credit_account,amount_minor,currency_id,reference,receipt,actor,request_id
339
+ FROM money_ledger WHERE id=?''', (row[2],))
340
+ require(ledger is not None and ledger[:5] == (
341
+ order['escrow_subject'], order['seller'], order['total_price_minor'],
342
+ CURRENCY_ID, 'order_release:' + order['id']) and
343
+ loads(ledger[5]) == delivery['receipt'] and
344
+ ledger[6:] == (order['buyer'], ledger_request_id) and
345
+ _balance(tx, order['escrow_subject']) == 0, 'escrow_decision_mismatch')
msg/market/orders.py ADDED
@@ -0,0 +1,218 @@
1
+ """New checkout contracts without changing published orders.buy@1/@2 semantics."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import timedelta
5
+
6
+ from msg.core.codec import canonical, digest, parse_time, wire
7
+ from msg.core.errors import require
8
+ from msg.core.models import HandlerOutput
9
+ from msg.market.escrow import settle, transition
10
+ from msg.market.policy import contract, snapshot
11
+ from msg.market.targets import (
12
+ enqueue_notification, notification_view, save_target, target_for, validate_target,
13
+ )
14
+ from msg.plugins.money import CURRENCY_ID, MAX_MINOR, _post_transfer
15
+ from msg.plugins.orders import _order_id, _row, _subject, _view, _viewer
16
+ from msg.plugins.schemas import IDENTIFIER, obj
17
+ from msg.plugins.store import _body, _listing, _package_row
18
+
19
+ HASH = {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}
20
+ ORDER = obj({'order_id': IDENTIFIER}, ('order_id',))
21
+ INTENT = {
22
+ 'listing_id': IDENTIFIER, 'listing_revision': IDENTIFIER,
23
+ 'quantity': {'type': 'integer', 'minimum': 1, 'maximum': 10**9},
24
+ 'currency_id': {'const': CURRENCY_ID},
25
+ 'total_price_minor': {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR},
26
+ 'email': {'type': 'string', 'minLength': 3, 'maxLength': 254},
27
+ }
28
+ INTENT_REQUIRED = ('listing_id','listing_revision','quantity','currency_id','total_price_minor')
29
+
30
+
31
+ def view(tx, order, viewer):
32
+ email = notification_view(tx, order)
33
+ projected = {**order, 'delivery_target': {**order['delivery_target'], 'email': email}}
34
+ result = _view(projected, viewer)
35
+ row = tx.one('SELECT digest FROM order_contracts WHERE order_id=?', (order['id'],))
36
+ if row:
37
+ result['order_digest'] = row[0]
38
+ result['contract_version'] = 3
39
+ locked = contract(tx, order['id'])
40
+ result['delivery_mode'] = locked['listing']['delivery_mode']
41
+ result['policy_digest'] = locked['policy']['policy_digest']
42
+ # Seller needs the *public encryption subkey*, not buyer's email.
43
+ if locked['recipient_key']:
44
+ result['recipient_key'] = locked['recipient_key']
45
+ result['email_status'] = email['state']
46
+ return result
47
+
48
+
49
+ async def create(app, tx, ctx, request):
50
+ buyer, args = _subject(ctx), request.arguments
51
+ listing = await _listing(app, ctx, request, tx, args['listing_id'])
52
+ body, revision = await _body(app, tx, listing)
53
+ require(body['mode'] == 'sale' and body['state'] == 'active' and
54
+ (body['expires_at'] is None or parse_time(body['expires_at']) > ctx.now),
55
+ 'listing_not_available')
56
+ require(args['listing_revision'] == listing.revision, 'listing_revision_conflict')
57
+ require(body['escrow_policy'] == 'escrow-v1', 'escrow_policy_unknown')
58
+ policy = snapshot(tx, body['dispute_policy'])
59
+ require(body['currency_id'] == args['currency_id'] == CURRENCY_ID, 'unsupported_currency')
60
+ total = body['price_minor'] * args['quantity']
61
+ require(0 < total <= MAX_MINOR, 'money_overflow')
62
+ require(total == args['total_price_minor'], 'price_changed')
63
+ sold = tx.one('''SELECT COALESCE(SUM(quantity),0) FROM store_orders
64
+ WHERE listing_id=? AND state NOT IN ('cancelled','refunded')''', (listing.id,))[0]
65
+ require(sold + args['quantity'] <= body['quantity'], 'quantity_unavailable')
66
+ require(listing.owner != buyer, 'self_purchase_forbidden')
67
+ require(body['item_kind'] != 'secret' or body['delivery_mode'] == 'sealed_manual',
68
+ 'secret_requires_buyer_encryption')
69
+ package_id = package_revision = package_digest = None
70
+ if body['delivery_mode'] == 'managed_instant':
71
+ require(body['item_kind'] in {'file','bundle','text'}, 'order_delivery_mode_unsupported')
72
+ package = await _package_row(tx, body['package_ref'])
73
+ require(package is not None and package[1] == listing.id and package[3] == listing.owner and
74
+ package[5] == body['item_kind'] and package[10] == 'managed_instant',
75
+ 'package_not_available')
76
+ package_id, package_revision, package_digest = package[0], package[4], package[8]
77
+ recipient_key = None
78
+ if body['delivery_mode'] == 'sealed_manual':
79
+ row = tx.one('''SELECT key_id,recipient,public_key FROM encryption_subkeys
80
+ WHERE subject=? AND is_primary=1 AND retired_at IS NULL''', (buyer,))
81
+ require(row is not None, 'delivery_recipient_key_required')
82
+ recipient_key = {'key_id': row[0], 'recipient': row[1], 'fingerprint': digest(row[2])}
83
+ target = await target_for(app, tx, buyer, args.get('email'))
84
+ order_id, now = _order_id(), wire(ctx.now)
85
+ escrow = 'esc_' + order_id[4:]
86
+ tx.execute("INSERT INTO ledger_accounts(id,kind,subject_id,source_id) VALUES (?,'order_escrow',NULL,?)",
87
+ (escrow, order_id), write=True)
88
+ tx.execute('''INSERT INTO store_orders
89
+ (id,buyer,seller,listing_id,listing_revision,package_id,package_revision,package_digest,
90
+ quantity,unit_price_minor,total_price_minor,currency_id,escrow_subject,escrow_policy,
91
+ dispute_policy,terms_digest,delivery_target,payment_intent_digest,payment_transaction_id,
92
+ state,created_at,funded_at,receipt_refs)
93
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,'created',?,NULL,'[]')''',
94
+ (order_id,buyer,listing.owner,listing.id,listing.revision,package_id,package_revision,
95
+ package_digest,args['quantity'],body['price_minor'],total,CURRENCY_ID,escrow,
96
+ body['escrow_policy'],body['dispute_policy'],digest(body['terms']),canonical(target).decode(),
97
+ request.payload_digest,now), write=True)
98
+ locked = {'order_id': order_id, 'version': 3, 'buyer': buyer, 'seller': listing.owner,
99
+ 'listing': body, 'listing_id': listing.id, 'escrow_subject': escrow,
100
+ 'listing_revision': listing.revision,
101
+ 'listing_digest': revision.content.digest, 'package_id': package_id,
102
+ 'package_revision': package_revision, 'package_digest': package_digest,
103
+ 'quantity': args['quantity'], 'total_price_minor': total,
104
+ 'terms_digest': digest(body['terms']), 'policy': policy,
105
+ 'recipient_key': recipient_key, 'handle_snapshot': target['handle_snapshot'],
106
+ 'buyer_principal': wire(ctx.principal), 'created_at': now}
107
+ tx.execute('INSERT INTO order_contracts(order_id,body,digest) VALUES (?,?,?)',
108
+ (order_id,canonical(locked).decode(),digest(locked)), write=True)
109
+ tx.execute('INSERT INTO order_deadlines(order_id,expires_at) VALUES (?,?)',
110
+ (order_id, wire(ctx.now+timedelta(seconds=policy['policy']['funding_timeout_seconds']))), write=True)
111
+ from msg.market.email import verification_message
112
+ order = _row(tx, order_id, buyer)
113
+ await verification_message(app, tx, ctx, request, order)
114
+ return order
115
+
116
+
117
+ async def fund(app, tx, ctx, request, order):
118
+ require(order['state'] == 'created', 'order_not_fundable')
119
+ locked = contract(tx, order['id'])
120
+ require(ctx.now < parse_time(order['created_at']) + timedelta(
121
+ seconds=locked['policy']['policy']['funding_timeout_seconds']), 'payment_intent_expired')
122
+ validate_target(tx, order)
123
+ receipt = _post_transfer(tx, sender=order['buyer'], recipient=order['escrow_subject'],
124
+ amount=order['total_price_minor'], actor=order['buyer'], request_id=request.request_id,
125
+ now=ctx.now, receipt_signer=app.receipt_signer, reference='order_fund:'+order['id'])
126
+ tx.execute('''UPDATE store_orders SET payment_transaction_id=?,payment_intent_digest=?,
127
+ funded_at=?,receipt_refs=? WHERE id=?''', (receipt['body']['transaction_id'],
128
+ request.payload_digest,wire(ctx.now),canonical([receipt['body']['transaction_id']]).decode(),
129
+ order['id']), write=True)
130
+ order.update(payment_transaction_id=receipt['body']['transaction_id'],
131
+ payment_intent_digest=request.payload_digest, funded_at=wire(ctx.now),
132
+ receipt_refs=[receipt['body']['transaction_id']])
133
+ await transition(tx, order, 'funded', now=ctx.now, actor=order['buyer'],
134
+ request_id=request.request_id, reason='signed_payment_intent')
135
+ tx.execute('UPDATE order_deadlines SET expires_at=? WHERE order_id=?',
136
+ (wire(ctx.now+timedelta(seconds=locked['policy']['policy']['delivery_timeout_seconds'])), order['id']), write=True)
137
+ if locked['listing']['delivery_mode'] == 'managed_instant':
138
+ from msg.market.delivery import automatic
139
+ await automatic(app, tx, ctx, request, order)
140
+ return receipt
141
+
142
+
143
+ def install(app, op):
144
+ from msg.market.email import install as install_email
145
+ install_email(app, op)
146
+
147
+ @op('orders.create', obj(INTENT, INTENT_REQUIRED), signature=True)
148
+ async def reserve(ctx, request, tx):
149
+ order = await create(app, tx, ctx, request)
150
+ return HandlerOutput(data={'order': view(tx, order, order['buyer'])})
151
+
152
+ @op('orders.buy', obj(INTENT, INTENT_REQUIRED), signature=True, version=3)
153
+ async def buy(ctx, request, tx):
154
+ order = await create(app, tx, ctx, request)
155
+ receipt = await fund(app, tx, ctx, request, order)
156
+ return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], order['buyer']), order['buyer']),
157
+ 'payment': receipt})
158
+
159
+ @op('orders.fund', obj({'order_id': IDENTIFIER, 'order_digest': HASH,
160
+ 'total_price_minor': INTENT['total_price_minor'], 'currency_id': {'const': CURRENCY_ID}},
161
+ ('order_id','order_digest','total_price_minor','currency_id')), signature=True)
162
+ async def pay(ctx, request, tx):
163
+ buyer = _subject(ctx)
164
+ order = _row(tx, request.arguments['order_id'], buyer)
165
+ require(order['buyer'] == buyer, 'order_not_found')
166
+ locked = contract(tx, order['id'])
167
+ require(digest(locked) == request.arguments['order_digest'] and
168
+ order['total_price_minor'] == request.arguments['total_price_minor'],
169
+ 'payment_intent_mismatch')
170
+ payment = await fund(app, tx, ctx, request, order)
171
+ return HandlerOutput(data={'order': view(tx, _row(tx, order['id'], buyer), buyer),
172
+ 'payment': payment})
173
+
174
+ @op('orders.cancel', ORDER, signature=True, version=2)
175
+ async def cancel(ctx, request, tx):
176
+ buyer = _subject(ctx)
177
+ order = _row(tx, request.arguments['order_id'], buyer)
178
+ require(order['buyer'] == buyer, 'order_not_found')
179
+ if order['state'] == 'created':
180
+ await transition(tx, order, 'cancelled', now=ctx.now, actor=buyer,
181
+ request_id=request.request_id, reason='buyer_cancelled')
182
+ elif order['state'] not in {'cancelled', 'refunded'}:
183
+ require(order['state'] == 'funded' and order['delivered_at'] is None,
184
+ 'order_not_cancellable')
185
+ await settle(app, tx, order, now=ctx.now, actor=buyer, request_id=request.request_id,
186
+ reason='buyer_cancelled', refund_minor=order['total_price_minor'])
187
+ return HandlerOutput(data={'order': view(tx, order, buyer)})
188
+
189
+ @op('orders.email_bind', obj({'order_id': IDENTIFIER, 'email': INTENT['email']},
190
+ ('order_id','email')), signature=True)
191
+ async def email_bind(ctx, request, tx):
192
+ buyer = _subject(ctx)
193
+ order = _row(tx, request.arguments['order_id'], buyer)
194
+ require(order['buyer'] == buyer, 'order_not_found')
195
+ contract(tx, order['id'])
196
+ old = order['delivery_target'].get('email', {})
197
+ require(order['state'] in {'created','funded','delivered','settled'}, 'order_closed')
198
+ require(order['delivered_at'] is None or
199
+ (old.get('endpoint_id') is None and
200
+ old.get('address_snapshot') == request.arguments['email']),
201
+ 'delivery_target_locked')
202
+ target = await target_for(app, tx, buyer, request.arguments['email'])
203
+ target['handle_snapshot'] = order['delivery_target']['handle_snapshot']
204
+ require(target['email'].get('endpoint_id') is not None, 'email_not_verified')
205
+ save_target(tx, order, target)
206
+ validate_target(tx, order, email=True)
207
+ if order['delivered_at']:
208
+ await enqueue_notification(app, tx, ctx, request, order)
209
+ return HandlerOutput(data={'order': view(tx, order, buyer)})
210
+
211
+ @op('orders.contract', ORDER, effect='read')
212
+ async def get_contract(ctx, request, tx):
213
+ viewer = _viewer(ctx)
214
+ order = _row(tx, request.arguments['order_id'], viewer)
215
+ locked = contract(tx, order['id'])
216
+ # Neither endpoint nor credential ceilings are part of seller projections.
217
+ return HandlerOutput(data={'order': view(tx, order, viewer), 'contract': {
218
+ k:v for k,v in locked.items() if k not in {'buyer_principal','handle_snapshot'}}})