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