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
@@ -0,0 +1,224 @@
1
+ """Owner-signed transitions for an existing custodial upgrade operation."""
2
+ from __future__ import annotations
3
+
4
+ import hmac
5
+ from dataclasses import replace
6
+
7
+ from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire
8
+ from msg.core.errors import require
9
+ from msg.core.models import AuditEvent, Credential, Event, HandlerOutput, ResourceRef, Signature
10
+ from msg.plugins.common import create_resource, new_id
11
+ from msg.security.age_keys import public_from_recipient
12
+ from msg.security.crypto import verify
13
+ from msg.security.custodial_migration import (ordered, owned_age_inventory, output_refs,
14
+ snapshot, stage_statement)
15
+ from msg.security.vault import open_signer, seal_retired_encryption_key, server_upgrade_proof
16
+
17
+
18
+ def activate_encryption_target(tx, subject, challenge, details, now):
19
+ """After both possession proofs, new writes must no longer target the vault key."""
20
+ key = details['new_encryption_key_id']
21
+ current = tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1', (subject,))
22
+ if current == (key,):
23
+ return
24
+ require(current == (details['old_encryption_key_id'],), 'custodial_encryption_target_changed')
25
+ tx.execute('UPDATE encryption_subkeys SET is_primary=0,retired_at=? WHERE subject=? AND is_primary=1',
26
+ (wire(now), subject), write=True)
27
+ tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
28
+ (key, subject, challenge['encryption_recipient'],
29
+ b64(public_from_recipient(challenge['encryption_recipient'])), wire(now), None, 1), write=True)
30
+
31
+
32
+ async def audit(tx, ctx, request, subject, kind, data, *, before=None):
33
+ event = Event(id=new_id('audit'), type=kind, time=ctx.now, request_id=request.request_id,
34
+ actor=ctx.principal.actor, subject=subject,
35
+ resources=(ResourceRef(id=subject),), data=data)
36
+ await tx.append_audit(AuditEvent(event=event, authority=(ResourceRef(id=subject),),
37
+ before_digest=digest(before) if before is not None else None, after_digest=digest(data),
38
+ previous_digest=None, entry_digest='', result='committed'))
39
+
40
+
41
+ def authorize_migration(ctx, subject, credential_id, status, details):
42
+ if subject.kind == 'custodial':
43
+ require(ctx.principal.method == 'token' and ctx.principal.credential_id == credential_id,
44
+ 'custodial_token_required')
45
+ require(status in {'pending', 'pending_rewrap'}, 'custodial_upgrade_not_pending')
46
+ else:
47
+ require(subject.kind == 'registered' and ctx.principal.method == 'signature' and
48
+ ctx.principal.credential_id == details['new_identity_key_id'] and status == 'completed',
49
+ 'custodial_upgrade_new_key_required')
50
+
51
+
52
+ async def transition(app, ctx, request, tx, subject):
53
+ args = request.arguments
54
+ row = tx.one('''SELECT credential_id,status,expires_at,challenge,ephemeral_nonce,
55
+ ephemeral_ciphertext,body FROM custodial_upgrades WHERE id=? AND subject=?''',
56
+ (args['challenge_id'], subject.resource_id))
57
+ require(row is not None, 'custodial_upgrade_not_pending')
58
+ challenge, details = loads(row[3]), loads(row[6])
59
+ authorize_migration(ctx, subject, row[0], row[1], details)
60
+ verify(unb64(challenge['public_key']), canonical(stage_statement(subject.resource_id, args,
61
+ request.request_id)), decode(Signature, args['migration_ack']), purpose='custodial-decision-v1')
62
+ if row[1] == 'pending':
63
+ require(args['action'] == 'refresh' and ctx.now < parse_time(row[2]), 'custodial_upgrade_not_pending_rewrap')
64
+ expected = server_upgrade_proof(app, subject.resource_id, challenge, row[4], row[5])
65
+ require(hmac.compare_digest(expected, args['age_proof']), 'age_possession_failed')
66
+ else:
67
+ require(hmac.compare_digest(details['age_possession_digest'], digest(args['age_proof'])),
68
+ 'age_possession_failed')
69
+ state = await snapshot(tx, subject.resource_id, row[1], challenge, details, now=ctx.now)
70
+ state.require_observation(args)
71
+ before = digest(details)
72
+ if args['action'] == 'refresh':
73
+ # Preserve disappeared history as unresolved work; never delete it from a
74
+ # manifest just because the source is no longer enumerable.
75
+ added = state.data['delta']['added']
76
+ require(added or state.data['delta']['missing'], 'custodial_inventory_unchanged')
77
+ details['age_inventory'] = ordered(details['age_inventory'] + added)
78
+ details['inventory_version'] = details.get('inventory_version', 1) + 1
79
+ source_keys = details.setdefault('source_keys', {})
80
+ for item in added:
81
+ key = tx.setting('keystore_key:' + item['revision'], details['old_encryption_key_id'])
82
+ source_keys[item['revision']] = key
83
+ if key == details['new_encryption_key_id']:
84
+ details.setdefault('rewrap_mappings', {})[item['revision']] = {
85
+ 'old': item, 'new': item, 'old_key_id': key, 'new_key_id': key,
86
+ 'recipient': challenge['encryption_recipient'], 'copy_kind': 'new_key_write',
87
+ 'created_at': wire(ctx.now)}
88
+ details['last_delta'] = dict(state.data['delta'], version=details['inventory_version'])
89
+ details.pop('resolution', None)
90
+ elif args['action'] == 'recovery_envelope':
91
+ state.unchanged()
92
+ require(row[1] != 'pending', 'custodial_upgrade_not_pending_rewrap')
93
+ if details.get('recovery_envelope') is None:
94
+ ciphertext = seal_retired_encryption_key(app, tx, subject.resource_id,
95
+ details['old_encryption_key_id'], challenge['encryption_recipient'], args['challenge_id'])
96
+ folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject.resource_id,))
97
+ require(folder is not None, 'keystore_missing')
98
+ resource = await create_resource(app, ctx, request, tx, parent=folder[0], type='keystore',
99
+ name='retired-key-' + digest(args['challenge_id'])[7:31] + '.age', body=ciphertext,
100
+ media_type='application/octet-stream', mode=0o600)
101
+ tx.set_setting('keystore_format:' + resource.revision, 'age')
102
+ tx.set_setting('keystore_key:' + resource.revision, details['new_encryption_key_id'])
103
+ details['recovery_envelope'] = {'subject_id': subject.resource_id,
104
+ 'challenge_id': args['challenge_id'], 'old_key_id': details['old_encryption_key_id'],
105
+ 'new_key_id': details['new_encryption_key_id'], 'recipient': challenge['encryption_recipient'],
106
+ 'purpose': 'retired-encryption-subkey-recovery', 'method': 'compatibility_recovery',
107
+ 'ciphertext': {'id': resource.id, 'revision': resource.revision,
108
+ 'ciphertext_digest': digest(ciphertext)}}
109
+ else:
110
+ require(args['action'] == 'finalize', 'invalid_custodial_transition')
111
+ resolution = args.get('resolution')
112
+ if resolution == 'verified' or state.data['delta']['added']:
113
+ state.unchanged()
114
+ require(not (row[1]=='completed' and state.data['retirement']['online_encryption_key_deleted']),
115
+ 'custodial_upgrade_already_finalized')
116
+ unresolved = state.data['unresolved_revisions']
117
+ require(args.get('reviewed_policy_version') == state.data['recovery_policy_version'],
118
+ 'custodial_recovery_policy_review_required')
119
+ if resolution == 'verified':
120
+ require(state.data['history_recoverable'], 'custodial_history_ack_required')
121
+ require(not args.get('loss_revisions'), 'custodial_loss_scope_mismatch')
122
+ else:
123
+ require(resolution in {'accept_loss', 'retain_decrypt'} and args.get('reason'),
124
+ 'custodial_resolution_required')
125
+ require(sorted(args.get('loss_revisions', ())) == unresolved,
126
+ 'custodial_loss_scope_mismatch')
127
+ if resolution == 'accept_loss':
128
+ require(bool(unresolved), 'custodial_loss_scope_mismatch')
129
+ require(resolution == 'retain_decrypt' or args['external_ciphertexts_migrated'],
130
+ 'custodial_external_history_unresolved')
131
+ details['resolution'] = {'decision': resolution, 'revisions': unresolved,
132
+ 'reason': args.get('reason'), 'inventory_digest': state.inventory_digest,
133
+ 'results_digest': state.data['results_digest'], 'request_id': request.request_id,
134
+ 'signature': wire(args['migration_ack']), 'reviewed_policy_version': args['reviewed_policy_version'],
135
+ 'external_migration': 'owner_declared_unverified' if args['external_ciphertexts_migrated']
136
+ else 'unknown_retained'}
137
+ details['external_ciphertexts_migrated'] = args['external_ciphertexts_migrated']
138
+ return await switch_identity(app, ctx, request, tx, subject, challenge, details,
139
+ retain_decrypt=resolution == 'retain_decrypt')
140
+ tx.execute('UPDATE custodial_upgrades SET body=? WHERE id=?',
141
+ (canonical(details).decode(), args['challenge_id']), write=True)
142
+ await audit(tx, ctx, request, subject.resource_id, 'identity.custodial_' + args['action'],
143
+ {'challenge_id': args['challenge_id'], 'before_digest': before,
144
+ 'after_digest': digest(details), 'decision_signature': wire(args['migration_ack'])})
145
+ return HandlerOutput(data=(await snapshot(tx, subject.resource_id, row[1], challenge, details,
146
+ now=ctx.now)).data)
147
+
148
+
149
+ async def switch_identity(app, ctx, request, tx, subject, challenge, details, *, retain_decrypt=False):
150
+ """One SQL transaction: switch identity, revoke tokens, and restrict/delete online keys."""
151
+ from msg.plugins.identity import issue_online
152
+ before = digest(details)
153
+ uid = subject.resource_id
154
+ new_key = details['new_identity_key_id']
155
+ switched = subject.kind == 'registered'
156
+ if not switched:
157
+ require(subject.kind == 'custodial', 'custodial_token_required')
158
+ phase = tx.one('SELECT status FROM custodial_upgrades WHERE id=? AND subject=?',
159
+ (challenge['challenge_id'], uid))
160
+ require(phase is not None, 'custodial_upgrade_not_pending')
161
+ signer = open_signer(app, tx, uid)
162
+ endorsement = signer.sign(canonical({'subject_id': uid, 'challenge_id': challenge['challenge_id'],
163
+ 'old_identity_key_id': details['old_identity_key_id'], 'new_identity_key_id': new_key,
164
+ 'new_encryption_key_id': details['new_encryption_key_id'],
165
+ 'inventory_digest': (await snapshot(tx, uid, phase[0], challenge, details)).inventory_digest}),
166
+ purpose='custodial-upgrade')
167
+ revoked = []
168
+ for (raw,) in tx.rows('SELECT body FROM credentials WHERE subject=?', (uid,)):
169
+ credential = decode(Credential, loads(raw))
170
+ if credential.revoked_at is None and credential.kind in {'token', 'signing_key'}:
171
+ await tx.save_credential(replace(credential, revoked_at=ctx.now), subject.auth_version)
172
+ revoked.append(credential.id)
173
+ credential = Credential(id=new_key, subject_id=uid, kind='signing_key',
174
+ verifier=unb64(challenge['public_key']), ceiling=app.primary_ceiling(),
175
+ not_before=ctx.now, expires_at=None, revoked_at=None)
176
+ await tx.save_credential(credential, subject.auth_version)
177
+ tx.execute('UPDATE identity_keys SET is_primary=0,retired_at=? WHERE subject=? AND is_primary=1',
178
+ (wire(ctx.now), uid), write=True)
179
+ tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
180
+ (new_key, uid, challenge['public_key'], wire(ctx.now), None, 1), write=True)
181
+ activate_encryption_target(tx, uid, challenge, details, ctx.now)
182
+ resource = await tx.resource(uid)
183
+ await tx.replace(replace(resource, name='@' + challenge['handle'],
184
+ generation=resource.generation + 1, modified_at=ctx.now, modified_by=ctx.principal.actor),
185
+ resource.generation)
186
+ await tx.update_identity(replace(subject, kind='registered', auth_version=subject.auth_version + 1),
187
+ subject.auth_version)
188
+ certificate = await issue_online(app, tx, uid, new_key, ctx, request,
189
+ authority_source={'kind': 'custodial_upgrade', 'challenge_id': challenge['challenge_id'],
190
+ 'new_key_id': new_key})
191
+ completed = {'status': 'completed', 'subject_id': uid, 'identity_key_id': new_key,
192
+ 'encryption_key_id': details['new_encryption_key_id'],
193
+ 'encryption_recipient': challenge['encryption_recipient'],
194
+ 'certificate_id': certificate.resource_id, 'previous_credential': ctx.principal.credential_id,
195
+ 'credential_id': new_key, 'signature_source': 'custodial'}
196
+ await audit(tx, ctx, request, uid, 'identity.custodial_tokens_revoked',
197
+ {'challenge_id': challenge['challenge_id'], 'credentials': revoked,
198
+ 'new_key_id': new_key, 'old_key_endorsement': wire(endorsement)})
199
+ else:
200
+ completed = dict(details['completed_result'])
201
+ if retain_decrypt:
202
+ tx.execute('''UPDATE custodial_vault SET status='decrypt_only',signing_nonce=NULL,
203
+ signing_ciphertext=NULL WHERE subject=?''', (uid,), write=True)
204
+ else:
205
+ tx.execute('''UPDATE custodial_vault SET status='destroyed',signing_nonce=NULL,
206
+ signing_ciphertext=NULL,age_nonce=NULL,age_ciphertext=NULL,destroyed_at=? WHERE subject=?''',
207
+ (wire(ctx.now), uid), write=True)
208
+ current = await snapshot(tx, uid, 'completed', challenge, details, now=ctx.now)
209
+ completed.update(completion_scope='identity_switch_only', phase=current.data['phase'],
210
+ history_recoverable=current.data['history_recoverable'],
211
+ verification_scope=current.data['verification_scope'],
212
+ server_tracked_age_revisions=len(details['age_inventory']), vault_destroyed=not retain_decrypt,
213
+ external_migration='owner_declared_unverified', external_ciphertexts_verified=False,
214
+ retirement=current.data['retirement'], resolution=details.get('resolution'),
215
+ challenge_id=challenge['challenge_id'], finalization_request_id=request.request_id)
216
+ details['completed_result'] = completed
217
+ tx.execute('''UPDATE custodial_upgrades SET status='completed',ephemeral_nonce=NULL,
218
+ ephemeral_ciphertext=NULL,body=? WHERE id=?''',
219
+ (canonical(details).decode(), challenge['challenge_id']), write=True)
220
+ await audit(tx, ctx, request, uid, 'identity.custodial_online_key_retirement',
221
+ {'challenge_id': challenge['challenge_id'], 'old_identity_key_id': details['old_identity_key_id'],
222
+ 'old_encryption_key_id': details['old_encryption_key_id'], 'retirement': current.data['retirement'],
223
+ 'resolution': details.get('resolution'), 'before_digest': before})
224
+ return HandlerOutput(resources=(ResourceRef(id=uid),), data=completed)
@@ -0,0 +1,230 @@
1
+ """Buyer-only in-site delivery of an immutable managed package.
2
+
3
+ The versioned checkout can prepare a package atomically. A signed acceptance
4
+ or an explicit, digest-bound checkout policy controls settlement; merely reading
5
+ or sending an email never claims a delivery.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from functools import partial
10
+
11
+ from msg.core.codec import b64, canonical, decode, digest, loads, wire
12
+ from msg.core.errors import require
13
+ from msg.core.models import BlobRef, HandlerOutput
14
+ from msg.plugins.common import new_id, registration
15
+ from msg.plugins.money import CURRENCY_ID, _balance, account_requirements
16
+ from msg.market.escrow import EscrowEngine
17
+ from msg.market.delivery_targets import validate_target
18
+ from msg.plugins.orders import _row as order_row, _subject
19
+ from msg.plugins.schemas import IDENTIFIER, obj
20
+ from msg.plugins.store import _package_row
21
+
22
+
23
+ MAX_INLINE_BYTES = 1024 * 1024
24
+ _COLUMNS = ('id', 'order_id', 'recipient_subject', 'kind', 'payload_refs',
25
+ 'manifest', 'package_digest', 'delivery_digest', 'channel',
26
+ 'state', 'prepared_at', 'claimed_at', 'receipt')
27
+
28
+
29
+ def _delivery(tx, order_id):
30
+ row = tx.one('''SELECT id,order_id,recipient_subject,kind,payload_refs,
31
+ manifest,package_digest,delivery_digest,channel,state,prepared_at,
32
+ claimed_at,receipt FROM store_deliveries WHERE order_id=?''', (order_id,))
33
+ if row is None:
34
+ return None
35
+ result = dict(zip(_COLUMNS, row))
36
+ result['payload_refs'] = loads(result['payload_refs'])
37
+ result['manifest'] = loads(result['manifest'])
38
+ result['receipt'] = loads(result['receipt']) if result['receipt'] else None
39
+ return result
40
+
41
+
42
+ def _buyer_order(tx, order_id, buyer):
43
+ row = order_row(tx, order_id, buyer)
44
+ require(row['buyer'] == buyer, 'order_not_found')
45
+ return row
46
+
47
+
48
+ async def _verified_delivery(app, tx, order, delivery):
49
+ validate_target(order, delivery)
50
+ require(delivery is not None and
51
+ delivery['recipient_subject'] == order['buyer'] and
52
+ delivery['package_digest'] == order['package_digest'] and
53
+ delivery['channel'] == 'site', 'delivery_mismatch')
54
+ kind, manifest, refs = await _package(app, tx, order)
55
+ body = {'delivery_id': delivery['id'], 'order_id': order['id'],
56
+ 'recipient_subject': order['buyer'], 'kind': kind,
57
+ 'manifest': manifest, 'payload_refs': refs,
58
+ 'package_digest': order['package_digest'], 'channel': 'site'}
59
+ require(delivery['kind'] == kind and delivery['manifest'] == manifest and
60
+ delivery['payload_refs'] == refs and
61
+ delivery['delivery_digest'] == digest(body), 'delivery_mismatch')
62
+
63
+
64
+ async def _package(app, tx, order):
65
+ require(order['package_id'] and order['package_revision'] and
66
+ order['package_digest'], 'delivery_package_mismatch')
67
+ row = await _package_row(tx, order['package_id'])
68
+ require(row is not None and row[1] == order['listing_id'] and
69
+ row[3] == order['seller'] and row[4] == order['package_revision'] and
70
+ row[8] == order['package_digest'] and row[10] == 'managed_instant' and
71
+ row[5] in {'file', 'bundle'}, 'delivery_package_mismatch')
72
+ manifest, refs = loads(row[6]), loads(row[7])
73
+ snapshot = {'seller': row[3], 'listing_id': row[1],
74
+ 'listing_revision': row[2], 'revision': row[4], 'kind': row[5],
75
+ 'manifest': manifest, 'payload_refs': refs,
76
+ 'total_size': row[9], 'delivery_mode': row[10]}
77
+ require(digest(snapshot) == order['package_digest'] and
78
+ row[9] <= MAX_INLINE_BYTES and row[9] >= 0,
79
+ 'delivery_package_mismatch')
80
+ require(refs and (row[5] == 'bundle' or len(refs) == 1),
81
+ 'delivery_package_mismatch')
82
+ total = 0
83
+ for ref in refs:
84
+ require(isinstance(ref, dict) and isinstance(ref.get('blob'), dict),
85
+ 'delivery_package_mismatch')
86
+ blob = decode(BlobRef, ref['blob'])
87
+ require(blob.size >= 0 and blob.size <= MAX_INLINE_BYTES,
88
+ 'delivery_package_mismatch')
89
+ data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES)
90
+ require(len(data) == blob.size and digest(data) == blob.digest,
91
+ 'delivery_package_mismatch')
92
+ total += blob.size
93
+ require(total == row[9], 'delivery_package_mismatch')
94
+ return row[5], manifest, refs
95
+
96
+
97
+ async def _payload(app, delivery):
98
+ files = []
99
+ for ref in delivery['payload_refs']:
100
+ blob = decode(BlobRef, ref['blob'])
101
+ data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES)
102
+ require(len(data) == blob.size and digest(data) == blob.digest,
103
+ 'delivery_package_mismatch')
104
+ files.append({'id': ref['id'], 'revision': ref['revision'],
105
+ 'media_type': blob.media_type, 'digest': blob.digest,
106
+ 'size': blob.size, 'data': b64(data)})
107
+ return files
108
+
109
+
110
+ async def prepare_managed(app, ctx, tx, order):
111
+ """Prepare inside the caller's transaction; never authenticate or commit here."""
112
+ buyer = order['buyer']
113
+ require(order['state'] == 'funded' and order['delivered_at'] is None,
114
+ 'order_not_deliverable')
115
+ validate_target(order)
116
+ require(order['currency_id'] == CURRENCY_ID and
117
+ _balance(tx, order['escrow_subject']) == order['total_price_minor'],
118
+ 'escrow_balance_mismatch')
119
+ kind, manifest, refs = await _package(app, tx, order)
120
+ delivery_id = new_id('dlv')
121
+ body = {'delivery_id': delivery_id, 'order_id': order['id'],
122
+ 'recipient_subject': buyer, 'kind': kind,
123
+ 'manifest': manifest, 'payload_refs': refs,
124
+ 'package_digest': order['package_digest'], 'channel': 'site'}
125
+ delivery_digest = digest(body)
126
+ tx.execute('''INSERT INTO store_deliveries
127
+ (id,order_id,recipient_subject,kind,payload_refs,manifest,
128
+ package_digest,delivery_digest,channel,state,prepared_at,claimed_at,receipt)
129
+ VALUES (?,?,?,?,?,?,?,?,?,'prepared',?,NULL,NULL)''',
130
+ (delivery_id, order['id'], buyer, kind, canonical(refs).decode(),
131
+ canonical(manifest).decode(), order['package_digest'],
132
+ delivery_digest, 'site', wire(ctx.now)), write=True)
133
+ changed = tx.execute('''UPDATE store_orders SET state='delivered',delivered_at=?
134
+ WHERE id=? AND buyer=? AND state='funded' AND delivered_at IS NULL''',
135
+ (wire(ctx.now), order['id'], buyer), write=True)
136
+ require(changed.rowcount == 1, 'order_not_deliverable')
137
+ return _delivery(tx, order['id'])
138
+
139
+
140
+ def delivery_summary(delivery):
141
+ return {'delivery_id': delivery['id'], 'order_id': delivery['order_id'],
142
+ 'state': delivery['state'], 'channel': delivery['channel'],
143
+ 'package_digest': delivery['package_digest'],
144
+ 'delivery_digest': delivery['delivery_digest']}
145
+
146
+
147
+ def install(app):
148
+ op, finish = registration(app, 'delivery', ('orders',))
149
+ # Account authority belongs to the operation contract, not just the handler:
150
+ # the executor must apply it before returning an idempotent cached result.
151
+ op = partial(op, requirements=account_requirements)
152
+
153
+ @op('delivery.prepare', obj({'order_id': IDENTIFIER}, ('order_id',)),
154
+ signature=True)
155
+ async def prepare(ctx, request, tx):
156
+ buyer = _subject(ctx)
157
+ order = _buyer_order(tx, request.arguments['order_id'], buyer)
158
+ delivery = await prepare_managed(app, ctx, tx, order)
159
+ return HandlerOutput(data={'delivery': delivery_summary(delivery)})
160
+
161
+ @op('delivery.get', obj({'order_id': IDENTIFIER}, ('order_id',)), effect='read')
162
+ async def get(ctx, request, tx):
163
+ buyer = ctx.principal.subject
164
+ require(buyer is not None and ctx.principal.actor == buyer,
165
+ 'order_not_found')
166
+ order = _buyer_order(tx, request.arguments['order_id'], buyer)
167
+ if tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (order['id'],)):
168
+ from msg.market.delivery import read
169
+ return await read(app, tx, ctx, request)
170
+ delivery = _delivery(tx, order['id'])
171
+ require(delivery is not None, 'delivery_not_found')
172
+ await _verified_delivery(app, tx, order, delivery)
173
+ from msg.market.delivery_notifications import notification_status
174
+ return HandlerOutput(data={'delivery': {
175
+ 'delivery_id': delivery['id'], 'order_id': order['id'],
176
+ 'recipient_subject': buyer, 'kind': delivery['kind'],
177
+ 'manifest': delivery['manifest'], 'package_digest': delivery['package_digest'],
178
+ 'delivery_digest': delivery['delivery_digest'], 'channel': 'site',
179
+ 'state': delivery['state'], 'prepared_at': delivery['prepared_at'],
180
+ 'claimed_at': delivery['claimed_at'],
181
+ 'payloads': await _payload(app, delivery),
182
+ 'notification': await notification_status(app, tx, order['id'])}})
183
+
184
+ @op('delivery.accept', obj({'order_id': IDENTIFIER,
185
+ 'delivery_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}},
186
+ ('order_id', 'delivery_digest')), signature=True)
187
+ async def accept(ctx, request, tx):
188
+ if tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (request.arguments['order_id'],)):
189
+ from msg.market.delivery import accept as market_accept
190
+ return await market_accept(app, tx, ctx, request)
191
+ receipt,decision,delivery = await EscrowEngine(app).settle(
192
+ ctx,request,tx,reason='buyer_accept')
193
+ return HandlerOutput(data={'order_id':request.arguments['order_id'],'state':'settled',
194
+ 'delivery_id':delivery['id'],'receipt':receipt,'decision':decision})
195
+
196
+ @op('delivery.claim', obj({'order_id': IDENTIFIER,
197
+ 'delivery_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}},
198
+ ('order_id', 'delivery_digest')), signature=True)
199
+ async def claim(ctx, request, tx):
200
+ # Checkout settlement is not evidence of receipt by the buyer. This
201
+ # later signed ACK only changes the delivery; it cannot debit escrow.
202
+ buyer = _subject(ctx)
203
+ order = _buyer_order(tx, request.arguments['order_id'], buyer)
204
+ delivery = _delivery(tx, order['id'])
205
+ await _verified_delivery(app, tx, order, delivery)
206
+ require(delivery['delivery_digest'] == request.arguments['delivery_digest'],
207
+ 'delivery_mismatch')
208
+ await EscrowEngine(app).verify_checkout_release(tx, order, delivery)
209
+ require(delivery['state'] == 'prepared', 'delivery_not_acceptable')
210
+ changed = tx.execute("""UPDATE store_deliveries SET state='claimed',claimed_at=?
211
+ WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
212
+ (wire(ctx.now), order['id'], buyer), write=True)
213
+ require(changed.rowcount == 1, 'delivery_not_acceptable')
214
+ return HandlerOutput(data={'order_id': order['id'],
215
+ 'delivery_id': delivery['id'], 'state': 'claimed'})
216
+
217
+ @op('delivery.notify', obj({'order_id': IDENTIFIER,
218
+ 'enabled': {'type': 'boolean'}}, ('order_id',)), signature=True)
219
+ async def notify(ctx, request, tx):
220
+ from msg.market.delivery_notifications import queue_notification
221
+ buyer = _subject(ctx)
222
+ order = _buyer_order(tx, request.arguments['order_id'], buyer)
223
+ await _verified_delivery(app, tx, order, _delivery(tx, order['id']))
224
+ status = await queue_notification(app, ctx, request, tx, order,
225
+ enabled=request.arguments.get('enabled', True))
226
+ return HandlerOutput(data={'order_id': order['id'], 'notification': status})
227
+
228
+ from msg.market.delivery import install as install_market
229
+ install_market(app, op)
230
+ finish()