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/plugins/bounty.py ADDED
@@ -0,0 +1,366 @@
1
+ """Prefunded bounties with one-use identity-key proof of possession.
2
+
3
+ All account movements run inside the executor's PostgreSQL write transaction.
4
+ An escrow LedgerAccount is not a Subject and has no credential or public transfer operation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from collections.abc import Mapping
10
+ from datetime import timedelta
11
+
12
+ from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire
13
+ from msg.core.errors import require
14
+ from msg.core.models import HandlerOutput, ResourceRef, Signature
15
+ from msg.plugins.common import (check_access, create_resource, new_id, operation_id,
16
+ registration)
17
+ from msg.plugins.money import account_requirements, CURRENCY_ID, MAX_MINOR, _balance, _post_transfer
18
+ from msg.plugins.schemas import IDENTIFIER, SIGNATURE, obj
19
+ from msg.security.crypto import verify
20
+
21
+
22
+ VERIFIER_ID = 'signature_pop_v1'
23
+ VERIFIER_VERSION = 1
24
+ CHALLENGE_TTL = 300
25
+
26
+
27
+ def _subject(ctx):
28
+ require(ctx.principal.subject is not None and
29
+ ctx.principal.actor == ctx.principal.subject and
30
+ ctx.principal.method == 'signature', 'signature_required')
31
+ return ctx.principal.subject
32
+
33
+
34
+ def _eligibility(value, subject):
35
+ if value == {'kind': 'any'}:
36
+ return True
37
+ # Unknown constraints would be published but never enforced; reject them.
38
+ require(isinstance(value, Mapping) and set(value) == {'kind', 'subjects'} and
39
+ value.get('kind') == 'allowlist' and
40
+ isinstance(value.get('subjects'), (list, tuple)) and
41
+ len(value['subjects']) <= 1000 and
42
+ all(isinstance(s, str) for s in value['subjects']),
43
+ 'invalid_bounty_eligibility')
44
+ return subject in value['subjects']
45
+
46
+
47
+ def _row(tx, listing_id):
48
+ row = tx.one('''SELECT listing_id,publisher,escrow_subject,reward_minor,budget_minor,
49
+ max_claims,claim_limit_per_subject,verifier_id,verifier_version,eligibility,
50
+ state,pause_reason,expires_at,created_at FROM bounty_listings WHERE listing_id=?''',
51
+ (listing_id,))
52
+ require(row is not None, 'bounty_not_found')
53
+ return dict(zip(('listing_id', 'publisher', 'escrow_subject', 'reward_minor',
54
+ 'budget_minor', 'max_claims', 'claim_limit_per_subject',
55
+ 'verifier_id', 'verifier_version', 'eligibility', 'state',
56
+ 'pause_reason', 'expires_at', 'created_at'),
57
+ (*row[:9], loads(row[9]), *row[10:])))
58
+
59
+
60
+ def _public(row, balance, claims, now=None):
61
+ # Derive read projections without writing lifecycle state during GET.
62
+ row = dict(row)
63
+ if row['state'] != 'closed':
64
+ if now is not None and row['expires_at'] is not None and parse_time(row['expires_at']) <= now:
65
+ row.update(state='paused', pause_reason='expired')
66
+ elif balance < row['reward_minor']:
67
+ row.update(state='paused', pause_reason='out_of_budget')
68
+ elif claims >= row['max_claims']:
69
+ row.update(state='paused', pause_reason='claims_exhausted')
70
+ return {key: row[key] for key in ('listing_id', 'publisher', 'reward_minor',
71
+ 'budget_minor', 'max_claims', 'claim_limit_per_subject', 'verifier_id',
72
+ 'verifier_version', 'eligibility', 'state', 'pause_reason', 'expires_at',
73
+ 'created_at')} | {'escrow_balance_minor': balance, 'paid_claims': claims}
74
+
75
+
76
+ def _claim_count(tx, listing_id, claimant=None):
77
+ if claimant is None:
78
+ return int(tx.one("SELECT COUNT(*) FROM bounty_claims WHERE listing_id=? AND status='paid'",
79
+ (listing_id,))[0])
80
+ return int(tx.one("SELECT COUNT(*) FROM bounty_claims WHERE listing_id=? AND claimant=? AND status='paid'",
81
+ (listing_id, claimant))[0])
82
+
83
+
84
+ def projection(tx, listing_id, now):
85
+ row = _row(tx, listing_id)
86
+ return _public(row, _balance(tx,row['escrow_subject']), _claim_count(tx,listing_id), now)
87
+
88
+
89
+ def _ready(tx, row, now, claimant):
90
+ require(row['state'] == 'active', 'bounty_not_active')
91
+ require(row['expires_at'] is None or parse_time(row['expires_at']) > now,
92
+ 'bounty_expired')
93
+ require(row['verifier_id'] == VERIFIER_ID and
94
+ row['verifier_version'] == VERIFIER_VERSION, 'bounty_verifier_unsupported')
95
+ require(_eligibility(row['eligibility'], claimant), 'bounty_ineligible')
96
+ require(_claim_count(tx, row['listing_id']) < row['max_claims'], 'bounty_claims_exhausted')
97
+ require(_claim_count(tx, row['listing_id'], claimant) < row['claim_limit_per_subject'],
98
+ 'bounty_subject_limit')
99
+ require(_balance(tx, row['escrow_subject']) >= row['reward_minor'],
100
+ 'bounty_out_of_budget')
101
+
102
+
103
+ def install(app):
104
+ op, finish = registration(app, 'bounty', ('store', 'money'))
105
+ amount = {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}
106
+
107
+ @op('bounty.create', obj({
108
+ 'name': {'type': 'string', 'minLength': 1, 'maxLength': 120},
109
+ 'terms': {'type': 'string', 'minLength': 1, 'maxLength': 16384},
110
+ 'reward_minor': amount, 'budget_minor': amount,
111
+ 'max_claims': {'type': 'integer', 'minimum': 1, 'maximum': 1000000},
112
+ 'claim_limit_per_subject': {'type': 'integer', 'minimum': 1, 'maximum': 1000},
113
+ 'eligibility': {'type': 'object'},
114
+ 'expires_at': {'type': ['string', 'null']},
115
+ 'currency_id': {'const': CURRENCY_ID},
116
+ 'verifier_id': {'const': VERIFIER_ID},
117
+ 'verifier_version': {'const': VERIFIER_VERSION},
118
+ }, ('name', 'terms', 'reward_minor', 'budget_minor', 'max_claims')),
119
+ signature=True, requirements=account_requirements)
120
+ async def create(ctx, request, tx):
121
+ publisher = _subject(ctx)
122
+ await app.authorizer.require_base(ctx.principal, operation_id(request), publisher, tx)
123
+ await check_access(app, ctx, request, tx, 't_store', 'create')
124
+ args = request.arguments
125
+ require(args.get('currency_id', CURRENCY_ID) == CURRENCY_ID,
126
+ 'unsupported_currency')
127
+ require(args.get('verifier_id', VERIFIER_ID) == VERIFIER_ID and
128
+ args.get('verifier_version', VERIFIER_VERSION) == VERIFIER_VERSION,
129
+ 'bounty_verifier_unsupported')
130
+ eligibility = args.get('eligibility', {'kind': 'any'})
131
+ _eligibility(eligibility, publisher)
132
+ expires_at = args.get('expires_at')
133
+ if expires_at is not None:
134
+ require(parse_time(expires_at) > ctx.now, 'bounty_expired')
135
+ reward, budget = args['reward_minor'], args['budget_minor']
136
+ require(budget <= MAX_MINOR and reward <= MAX_MINOR, 'invalid_money_amount')
137
+ require(_balance(tx, publisher) >= budget, 'insufficient_funds')
138
+ owner = await tx.subject(publisher)
139
+ require(owner.kind in {'registered', 'custodial'} and not owner.local_only,
140
+ 'money_subject_required')
141
+ listing_id = new_id('bty')
142
+ escrow = new_id('esc')
143
+ # The prefunded escrow is an account, not a signable Subject.
144
+ tx.execute('''INSERT INTO ledger_accounts(id,kind,subject_id,source_id)
145
+ VALUES (?,'bounty_escrow',NULL,?)''', (escrow,listing_id), write=True)
146
+ body = {'mode': 'bounty', 'terms': args['terms'], 'reward_minor': reward,
147
+ 'currency_id': CURRENCY_ID, 'budget_minor': budget,
148
+ 'max_claims': args['max_claims'],
149
+ 'claim_limit_per_subject': args.get('claim_limit_per_subject', 1),
150
+ 'verifier_id': VERIFIER_ID, 'verifier_version': VERIFIER_VERSION,
151
+ 'eligibility': eligibility, 'expires_at': expires_at,
152
+ 'state': 'active' if budget >= reward else 'paused'}
153
+ resource = await create_resource(app, ctx, request, tx, parent='t_store',
154
+ type='listing', name=args['name'], resource_id=listing_id,
155
+ body=canonical(body), media_type='application/json', mode=0o644)
156
+ state = body['state']
157
+ tx.execute('''INSERT INTO bounty_listings
158
+ (listing_id,publisher,escrow_subject,reward_minor,budget_minor,max_claims,
159
+ claim_limit_per_subject,verifier_id,verifier_version,eligibility,state,
160
+ pause_reason,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
161
+ (listing_id, publisher, escrow, reward, budget, args['max_claims'],
162
+ body['claim_limit_per_subject'], VERIFIER_ID, VERIFIER_VERSION,
163
+ canonical(eligibility).decode(), state,
164
+ 'out_of_budget' if state == 'paused' else None,
165
+ expires_at, wire(ctx.now)), write=True)
166
+ funding = _post_transfer(tx, sender=publisher, recipient=escrow,
167
+ amount=budget, actor=publisher, request_id=request.request_id,
168
+ now=ctx.now, receipt_signer=app.receipt_signer,
169
+ reference='bounty_fund:' + listing_id)
170
+ return HandlerOutput(resources=(ResourceRef(id=listing_id, revision=resource.revision),),
171
+ data={'bounty': _public(_row(tx, listing_id), budget, 0),
172
+ 'funding': funding})
173
+
174
+ @op('bounty.get', obj({'listing_id': IDENTIFIER}, ('listing_id',)), effect='read')
175
+ async def get(ctx, request, tx):
176
+ row = _row(tx, request.arguments['listing_id'])
177
+ await check_access(app, ctx, request, tx, row['listing_id'], 'read')
178
+ return HandlerOutput(data={'bounty': projection(tx,row['listing_id'],ctx.now)})
179
+
180
+ @op('bounty.top_up', obj({'listing_id': IDENTIFIER, 'amount_minor': amount},
181
+ ('listing_id', 'amount_minor')), signature=True, requirements=account_requirements)
182
+ async def top_up(ctx, request, tx):
183
+ publisher = _subject(ctx)
184
+ row = _row(tx, request.arguments['listing_id'])
185
+ require(row['publisher'] == publisher, 'bounty_not_found')
186
+ await check_access(app, ctx, request, tx, row['listing_id'], 'write')
187
+ require(row['state'] != 'closed', 'bounty_closed')
188
+ require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now,
189
+ 'bounty_expired')
190
+ amount = request.arguments['amount_minor']
191
+ require(row['budget_minor'] + amount <= MAX_MINOR, 'money_overflow')
192
+ receipt = _post_transfer(tx, sender=publisher, recipient=row['escrow_subject'],
193
+ amount=amount, actor=publisher, request_id=request.request_id,
194
+ now=ctx.now, receipt_signer=app.receipt_signer,
195
+ reference='bounty_top_up:' + row['listing_id'])
196
+ balance = _balance(tx, row['escrow_subject'])
197
+ state = ('active' if (row['state'] == 'paused' and
198
+ row['pause_reason'] == 'out_of_budget' and
199
+ balance >= row['reward_minor'] and
200
+ _claim_count(tx, row['listing_id']) < row['max_claims']) else row['state'])
201
+ tx.execute('''UPDATE bounty_listings SET budget_minor=?,state=?,pause_reason=?
202
+ WHERE listing_id=?''', (row['budget_minor'] + amount, state,
203
+ None if state == 'active' else row['pause_reason'], row['listing_id']),
204
+ write=True)
205
+ return HandlerOutput(data={'bounty': _public(_row(tx, row['listing_id']),
206
+ balance, _claim_count(tx, row['listing_id'])), 'funding': receipt})
207
+
208
+ @op('bounty.challenge', obj({'listing_id': IDENTIFIER}, ('listing_id',)),
209
+ signature=True, requirements=account_requirements)
210
+ async def challenge(ctx, request, tx):
211
+ claimant = _subject(ctx)
212
+ row = _row(tx, request.arguments['listing_id'])
213
+ await check_access(app, ctx, request, tx, row['listing_id'], 'read')
214
+ _ready(tx, row, ctx.now, claimant)
215
+ key = tx.one('''SELECT key_id FROM identity_keys WHERE subject=? AND
216
+ is_primary=1 AND retired_at IS NULL''', (claimant,))
217
+ require(key is not None and key[0] == ctx.principal.credential_id,
218
+ 'current_identity_key_required')
219
+ payload = {'challenge_id': new_id('bc'), 'listing_id': row['listing_id'],
220
+ 'claimant_subject_id': claimant, 'nonce': b64(os.urandom(24)),
221
+ 'issued_at': wire(ctx.now),
222
+ 'expires_at': wire(ctx.now + timedelta(seconds=CHALLENGE_TTL)),
223
+ 'verifier_version': VERIFIER_VERSION}
224
+ tx.execute('''INSERT INTO bounty_challenges
225
+ (id,listing_id,claimant,key_id,nonce,issued_at,expires_at,
226
+ verifier_version,payload,consumed_at) VALUES (?,?,?,?,?,?,?,?,?,NULL)''',
227
+ (payload['challenge_id'], row['listing_id'], claimant, key[0],
228
+ payload['nonce'], payload['issued_at'], payload['expires_at'],
229
+ VERIFIER_VERSION, canonical(payload).decode()), write=True)
230
+ return HandlerOutput(data={'challenge': payload})
231
+
232
+ @op('bounty.close', obj({'listing_id': IDENTIFIER}, ('listing_id',)),
233
+ signature=True, requirements=account_requirements)
234
+ async def close(ctx, request, tx):
235
+ publisher = _subject(ctx)
236
+ row = _row(tx, request.arguments['listing_id'])
237
+ require(row['publisher'] == publisher, 'bounty_not_found')
238
+ await check_access(app, ctx, request, tx, row['listing_id'], 'write')
239
+ require(row['state'] != 'closed', 'bounty_closed')
240
+ remaining = _balance(tx, row['escrow_subject'])
241
+ refund = None
242
+ if remaining:
243
+ refund = _post_transfer(tx, sender=row['escrow_subject'],
244
+ recipient=publisher, amount=remaining, actor=publisher,
245
+ request_id=request.request_id, now=ctx.now,
246
+ receipt_signer=app.receipt_signer,
247
+ reference='bounty_close_return:' + row['listing_id'], kind='refund')
248
+ tx.execute("UPDATE bounty_listings SET state='closed',pause_reason=NULL WHERE listing_id=?",
249
+ (row['listing_id'],), write=True)
250
+ return HandlerOutput(data={'bounty': _public(_row(tx, row['listing_id']),
251
+ 0, _claim_count(tx, row['listing_id'])), 'returned': refund})
252
+
253
+ @op('bounty.claim', obj({'challenge_id': IDENTIFIER,
254
+ 'proof': SIGNATURE}, ('challenge_id', 'proof')), signature=True, requirements=account_requirements)
255
+ async def claim(ctx, request, tx):
256
+ claimant = _subject(ctx)
257
+ row = tx.one('''SELECT listing_id,claimant,key_id,expires_at,
258
+ verifier_version,payload,consumed_at,nonce,issued_at FROM bounty_challenges WHERE id=?''',
259
+ (request.arguments['challenge_id'],))
260
+ require(row is not None and row[1] == claimant, 'bounty_challenge_not_found')
261
+ listing = _row(tx, row[0])
262
+ await check_access(app, ctx, request, tx, listing['listing_id'], 'read')
263
+ _ready(tx, listing, ctx.now, claimant)
264
+ require(row[6] is None, 'bounty_challenge_consumed')
265
+ require(parse_time(row[3]) > ctx.now, 'bounty_challenge_expired')
266
+ require(row[4] == VERIFIER_VERSION, 'bounty_verifier_unsupported')
267
+ current = tx.one('''SELECT key_id,public_key FROM identity_keys WHERE subject=?
268
+ AND is_primary=1 AND retired_at IS NULL''', (claimant,))
269
+ require(current is not None and current[0] == row[2] and
270
+ current[0] == ctx.principal.credential_id,
271
+ 'current_identity_key_required')
272
+ proof = decode(Signature, request.arguments['proof'])
273
+ require(proof.key_id == current[0], 'bounty_proof_key_mismatch')
274
+ payload = loads(row[5])
275
+ require(payload == {'challenge_id':request.arguments['challenge_id'],
276
+ 'listing_id':row[0], 'claimant_subject_id':claimant, 'nonce':row[7],
277
+ 'issued_at':row[8], 'expires_at':row[3], 'verifier_version':row[4]},
278
+ 'bounty_challenge_mismatch')
279
+ verify(unb64(current[1], limit=32), canonical(payload), proof,
280
+ purpose='bounty-pop-v1')
281
+ proof_digest = digest({'payload': payload, 'proof': wire(proof)})
282
+ receipt = _post_transfer(tx, sender=listing['escrow_subject'],
283
+ recipient=claimant, amount=listing['reward_minor'], actor=claimant,
284
+ request_id=request.request_id, now=ctx.now,
285
+ receipt_signer=app.receipt_signer,
286
+ reference='bounty_claim:' + listing['listing_id'])
287
+ transaction_id = receipt['body']['transaction_id']
288
+ claim_id = new_id('bclaim')
289
+ tx.execute('''INSERT INTO bounty_claims
290
+ (id,listing_id,claimant,challenge_id,proof_digest,status,reward_minor,
291
+ transaction_id,claimed_at) VALUES (?,?,?,?,?,?,?,?,?)''',
292
+ (claim_id, listing['listing_id'], claimant,
293
+ request.arguments['challenge_id'], proof_digest, 'paid',
294
+ listing['reward_minor'], transaction_id, wire(ctx.now)), write=True)
295
+ consumed = tx.execute('''UPDATE bounty_challenges SET consumed_at=? WHERE id=? AND
296
+ consumed_at IS NULL''', (wire(ctx.now), request.arguments['challenge_id']),
297
+ write=True)
298
+ require(consumed.rowcount == 1, 'bounty_challenge_consumed')
299
+ balance = _balance(tx, listing['escrow_subject'])
300
+ paid_count = _claim_count(tx, listing['listing_id'])
301
+ reason = ('out_of_budget' if balance < listing['reward_minor'] else
302
+ 'claims_exhausted' if paid_count >= listing['max_claims'] else None)
303
+ if reason:
304
+ tx.execute("UPDATE bounty_listings SET state='paused',pause_reason=? WHERE listing_id=?",
305
+ (reason,listing['listing_id']), write=True)
306
+ from msg.plugins.communication import event_id
307
+ notice = {'id': new_id('message'), 'sender': None, 'actor': claimant,
308
+ 'recipient': claimant,
309
+ 'resource': wire(ResourceRef(id=listing['listing_id'])),
310
+ 'time': wire(ctx.now), 'state': 'delivered', 'source': 'bounty_claim',
311
+ 'listing_id': listing['listing_id'], 'claim_id': claim_id,
312
+ 'transaction_id': transaction_id}
313
+ tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
314
+ (notice['id'], None, claimant, listing['listing_id'],
315
+ event_id(request, claimant), canonical(notice).decode()), write=True)
316
+ return HandlerOutput(resources=(ResourceRef(id=listing['listing_id']),),
317
+ data={'claim': {'id': claim_id,
318
+ 'listing_id': listing['listing_id'], 'claimant': claimant,
319
+ 'challenge_id': request.arguments['challenge_id'],
320
+ 'proof_digest': proof_digest, 'status': 'paid',
321
+ 'reward_minor': listing['reward_minor'],
322
+ 'transaction_id': transaction_id, 'claimed_at': wire(ctx.now)},
323
+ 'reward': receipt, 'escrow_balance_minor': balance})
324
+
325
+ @op('bounty.pause', obj({'listing_id':IDENTIFIER},('listing_id',)), signature=True, requirements=account_requirements)
326
+ async def pause(ctx,request,tx):
327
+ row = _row(tx,request.arguments['listing_id'])
328
+ require(row['publisher'] == _subject(ctx),'bounty_not_found')
329
+ await check_access(app,ctx,request,tx,row['listing_id'],'write')
330
+ require(row['state'] != 'closed','bounty_closed')
331
+ tx.execute("UPDATE bounty_listings SET state='paused',pause_reason='publisher_paused' WHERE listing_id=?",
332
+ (row['listing_id'],),write=True)
333
+ return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)})
334
+
335
+ @op('bounty.resume', obj({'listing_id':IDENTIFIER},('listing_id',)), signature=True, requirements=account_requirements)
336
+ async def resume(ctx,request,tx):
337
+ row = _row(tx,request.arguments['listing_id'])
338
+ require(row['publisher'] == _subject(ctx),'bounty_not_found')
339
+ await check_access(app,ctx,request,tx,row['listing_id'],'write')
340
+ require(row['state'] != 'closed','bounty_closed')
341
+ require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now,'bounty_expired')
342
+ require(_claim_count(tx,row['listing_id']) < row['max_claims'],'bounty_claims_exhausted')
343
+ require(_balance(tx,row['escrow_subject']) >= row['reward_minor'],'bounty_out_of_budget')
344
+ tx.execute("UPDATE bounty_listings SET state='active',pause_reason=NULL WHERE listing_id=?",
345
+ (row['listing_id'],),write=True)
346
+ return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)})
347
+
348
+ @op('bounty.claims',obj({'listing_id':IDENTIFIER,'cursor':IDENTIFIER,
349
+ 'limit':{'type':'integer','minimum':1,'maximum':100}},('listing_id',)),effect='read', requirements=account_requirements)
350
+ async def claims(ctx,request,tx):
351
+ from msg.plugins.money import _owner
352
+ subject = _owner(ctx)
353
+ row = _row(tx,request.arguments['listing_id'])
354
+ await check_access(app,ctx,request,tx,row['listing_id'],'read')
355
+ args = request.arguments
356
+ limit = args.get('limit',50)
357
+ rows = tx.rows('''SELECT id,claimant,challenge_id,proof_digest,status,reward_minor,
358
+ transaction_id,claimed_at FROM bounty_claims WHERE listing_id=? AND
359
+ (claimant=? OR ?=?) AND id>? ORDER BY id LIMIT ?''',
360
+ (row['listing_id'],subject,subject,row['publisher'],args.get('cursor',''),limit+1))
361
+ items = [dict(zip(('id','claimant','challenge_id','proof_digest','status',
362
+ 'reward_minor','transaction_id','claimed_at'),r)) for r in rows[:limit]]
363
+ return HandlerOutput(data={'claims':items,
364
+ 'next_cursor':items[-1]['id'] if len(rows)>limit else None})
365
+
366
+ finish()
@@ -0,0 +1,261 @@
1
+ """Explicit handoff and expiring work leases; neither grants authority."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import timedelta
5
+
6
+ from msg.core.codec import canonical, loads, parse_time, wire
7
+ from msg.core.errors import Failure, require
8
+ from msg.core.models import HandlerOutput
9
+ from msg.plugins.common import check_access, new_id, operation_id, resolve
10
+ from msg.plugins.schemas import IDENTIFIER, STRING, obj
11
+
12
+
13
+ MAX_LEASE_TTL = timedelta(days=7)
14
+ MAX_HANDOFF_REFS = 16
15
+
16
+
17
+ async def _self(app, ctx, request, tx):
18
+ subject = ctx.principal.subject
19
+ require(subject is not None and ctx.principal.actor == subject, 'collaboration_subject_required')
20
+ await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx)
21
+ return subject
22
+
23
+
24
+ async def _safe_ref(app, ctx, request, tx, value):
25
+ rid = await resolve(tx, value)
26
+ resource = await tx.resource(rid)
27
+ chain = (*await tx.ancestors(rid), resource)
28
+ require(resource.state == 'active' and not any(
29
+ item.id in {'r_agents', 'r_rules', 't_last_will'} or
30
+ item.type in {'dm_conversation', 'credential', 'certificate', 'csr', 'legacy_directive', 'tool'}
31
+ for item in chain), 'collaboration_ref_forbidden')
32
+ require(not any(parent.type == 'user' and child.name in
33
+ {'keystore', 'SOUL.md', 'AGENTS.md', 'todos'}
34
+ for parent, child in zip(chain, chain[1:])), 'collaboration_ref_forbidden')
35
+ require(tx.one('SELECT 1 FROM dm_conversations WHERE resource_id IN (' +
36
+ ','.join('?' for _ in chain) + ') LIMIT 1',
37
+ tuple(item.id for item in chain)) is None, 'collaboration_ref_forbidden')
38
+ require(not any(tx.setting('hosting_preview:' + item.id) or
39
+ tx.setting('hosting_preview_file:' + item.id) for item in chain),
40
+ 'collaboration_ref_forbidden')
41
+ await check_access(app, ctx, request, tx, rid, 'read')
42
+ return rid
43
+
44
+
45
+ async def _visible_refs(app, ctx, request, tx, refs):
46
+ output = []
47
+ for rid in refs:
48
+ try:
49
+ await check_access(app, ctx, request, tx, rid, 'read')
50
+ except Failure as exc:
51
+ if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate',
52
+ 'delegation_scope', 'ancestor_inactive', 'not_found'}:
53
+ continue
54
+ raise
55
+ output.append(rid)
56
+ return output
57
+
58
+
59
+ def _notice(tx, *, sender, recipient, handoff_id, status, now):
60
+ # The resource is the recipient's own user resource: Inbox can filter it by
61
+ # current ACL. No handoff message, target name, or private ref is copied.
62
+ record = {'id': new_id('message'), 'sender': sender, 'recipient': recipient,
63
+ 'resource': {'id': recipient}, 'source': 'handoff',
64
+ 'handoff_id': handoff_id, 'status': status, 'time': wire(now)}
65
+ tx.execute('INSERT INTO messages (id,sender,recipient,resource,event_id,body) '
66
+ 'VALUES (?,?,?,?,?,?)',
67
+ (record['id'], sender, recipient, recipient, None,
68
+ canonical(record).decode()), write=True)
69
+
70
+
71
+ def _summary(record):
72
+ return {key: record[key] for key in ('id', 'status', 'generation')}
73
+
74
+
75
+ def install(app, op):
76
+ @op('communication.handoff_create', obj({
77
+ 'to_subject': IDENTIFIER,
78
+ 'resource_refs': {'type': 'array', 'items': IDENTIFIER,
79
+ 'maxItems': MAX_HANDOFF_REFS, 'uniqueItems': True},
80
+ 'message': {'type': 'string', 'maxLength': 4096},
81
+ 'next_action': {'type': 'string', 'maxLength': 1024},
82
+ }, ('to_subject', 'resource_refs')))
83
+ async def handoff_create(ctx, request, tx):
84
+ sender = await _self(app, ctx, request, tx)
85
+ recipient = await resolve(tx, request.arguments['to_subject'])
86
+ target_subject = await tx.subject(recipient)
87
+ require(not target_subject.local_only, 'invalid_handoff_recipient')
88
+ require(recipient != sender, 'invalid_handoff_recipient')
89
+ refs = [await _safe_ref(app, ctx, request, tx, value)
90
+ for value in request.arguments['resource_refs']]
91
+ record = {'id': new_id('handoff'), 'from_subject': sender,
92
+ 'to_subject': recipient, 'resource_refs': refs,
93
+ 'message': request.arguments.get('message', ''),
94
+ 'next_action': request.arguments.get('next_action'),
95
+ 'created_at': wire(ctx.now), 'updated_at': wire(ctx.now),
96
+ 'status': 'pending', 'generation': 1}
97
+ tx.execute('INSERT INTO handoffs VALUES (?,?,?,?,?,?)',
98
+ (record['id'], sender, recipient, 'pending', 1,
99
+ canonical(record).decode()), write=True)
100
+ _notice(tx, sender=sender, recipient=recipient, handoff_id=record['id'],
101
+ status='pending', now=ctx.now)
102
+ # Mutation results are replayed by the executor without running this
103
+ # handler again. Keep target refs only in ACL-checked read views.
104
+ return HandlerOutput(data={'handoff': _summary(record)})
105
+
106
+ @op('communication.handoff_get', obj({'id': IDENTIFIER}, ('id',)), effect='read')
107
+ async def handoff_get(ctx, request, tx):
108
+ subject = await _self(app, ctx, request, tx)
109
+ row = tx.one('SELECT from_subject,to_subject,body FROM handoffs WHERE id=?',
110
+ (request.arguments['id'],))
111
+ require(row is not None and subject in row[:2], 'handoff_not_found')
112
+ record = loads(row[2])
113
+ record['resource_refs'] = await _visible_refs(
114
+ app, ctx, request, tx, record['resource_refs'])
115
+ # Handoff text is authored for its participant, but it cannot carry a
116
+ # private target's body via our own projection.
117
+ return HandlerOutput(data={'handoff': record})
118
+
119
+ @op('communication.handoff_list', obj({
120
+ 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
121
+ 'after': IDENTIFIER}), effect='read')
122
+ async def handoff_list(ctx, request, tx):
123
+ subject = await _self(app, ctx, request, tx)
124
+ limit = request.arguments.get('limit', 50)
125
+ rows = tx.rows('SELECT id,body FROM handoffs WHERE '
126
+ '(from_subject=? OR to_subject=?) AND id>? ORDER BY id LIMIT ?',
127
+ (subject, subject, request.arguments.get('after', ''), limit + 1))
128
+ records = []
129
+ for _, raw in rows[:limit]:
130
+ record = loads(raw)
131
+ record['resource_refs'] = await _visible_refs(
132
+ app, ctx, request, tx, record['resource_refs'])
133
+ records.append(record)
134
+ return HandlerOutput(data={'items': records,
135
+ 'next_after': rows[limit - 1][0] if len(rows) > limit else None})
136
+
137
+ @op('communication.handoff_decide', obj({
138
+ 'id': IDENTIFIER, 'decision': {'enum': ['accept', 'reject', 'cancel']},
139
+ 'expected_generation': {'type': 'integer', 'minimum': 1},
140
+ }, ('id', 'decision', 'expected_generation')))
141
+ async def handoff_decide(ctx, request, tx):
142
+ subject = await _self(app, ctx, request, tx)
143
+ row = tx.one('SELECT from_subject,to_subject,status,generation,body FROM handoffs WHERE id=?',
144
+ (request.arguments['id'],))
145
+ require(row is not None and subject in row[:2], 'handoff_not_found')
146
+ sender, recipient, state, generation, raw = row
147
+ decision = request.arguments['decision']
148
+ require(state == 'pending', 'handoff_not_pending')
149
+ require((decision == 'cancel' and subject == sender) or
150
+ (decision in {'accept', 'reject'} and subject == recipient),
151
+ 'handoff_decision_forbidden')
152
+ require(generation == request.arguments['expected_generation'], 'generation_conflict')
153
+ status = {'accept': 'accepted', 'reject': 'rejected', 'cancel': 'cancelled'}[decision]
154
+ record = loads(raw)
155
+ record.update(status=status, generation=generation + 1,
156
+ updated_at=wire(ctx.now))
157
+ updated = tx.execute('UPDATE handoffs SET status=?,generation=?,body=? '
158
+ 'WHERE id=? AND status=? AND generation=?',
159
+ (status, generation + 1, canonical(record).decode(),
160
+ request.arguments['id'], 'pending', generation), write=True)
161
+ require(updated.rowcount == 1, 'generation_conflict')
162
+ _notice(tx, sender=subject, recipient=recipient if subject == sender else sender,
163
+ handoff_id=record['id'], status=status, now=ctx.now)
164
+ return HandlerOutput(data={'handoff': _summary(record)})
165
+
166
+ @op('communication.lease_acquire', obj({
167
+ 'target': IDENTIFIER, 'purpose': {'type': 'string', 'minLength': 1, 'maxLength': 1024},
168
+ 'expires_at': STRING,
169
+ }, ('target', 'purpose', 'expires_at')))
170
+ async def lease_acquire(ctx, request, tx):
171
+ holder = await _self(app, ctx, request, tx)
172
+ target = await _safe_ref(app, ctx, request, tx, request.arguments['target'])
173
+ expires = parse_time(request.arguments['expires_at'])
174
+ require(ctx.now < expires <= ctx.now + MAX_LEASE_TTL, 'invalid_lease_expiry')
175
+ record = {'id': new_id('lease'), 'holder': holder, 'target': target,
176
+ 'purpose': request.arguments['purpose'], 'acquired_at': wire(ctx.now),
177
+ 'expires_at': wire(expires), 'status': 'active', 'generation': 1}
178
+ tx.execute('INSERT INTO collaboration_leases VALUES (?,?,?,?,?,?,?)',
179
+ (record['id'], holder, target, 'active', 1,
180
+ wire(expires), canonical(record).decode()), write=True)
181
+ return HandlerOutput(data={'lease': _summary(record)})
182
+
183
+ async def _lease(ctx, request, tx, *, mutation=False):
184
+ holder = await _self(app, ctx, request, tx)
185
+ row = tx.one('SELECT holder,target,status,generation,expires_at,body '
186
+ 'FROM collaboration_leases WHERE id=?', (request.arguments['id'],))
187
+ require(row is not None and row[0] == holder, 'lease_not_found')
188
+ # A lease cannot keep a target visible after permission is revoked.
189
+ await _safe_ref(app, ctx, request, tx, row[1])
190
+ return row, loads(row[5])
191
+
192
+ @op('communication.lease_get', obj({'id': IDENTIFIER}, ('id',)), effect='read')
193
+ async def lease_get(ctx, request, tx):
194
+ row, record = await _lease(ctx, request, tx)
195
+ record['effective_status'] = ('expired' if row[2] == 'active' and
196
+ parse_time(row[4]) <= ctx.now else row[2])
197
+ return HandlerOutput(data={'lease': record})
198
+
199
+ @op('communication.lease_list', obj({
200
+ 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
201
+ 'after': IDENTIFIER}), effect='read')
202
+ async def lease_list(ctx, request, tx):
203
+ holder = await _self(app, ctx, request, tx)
204
+ limit = request.arguments.get('limit', 50)
205
+ rows = tx.rows('SELECT id,target,status,expires_at,body FROM collaboration_leases '
206
+ 'WHERE holder=? AND id>? ORDER BY id',
207
+ (holder, request.arguments.get('after', '')))
208
+ records = []
209
+ more = False
210
+ for _, target, status, expires, raw in rows:
211
+ try:
212
+ await _safe_ref(app, ctx, request, tx, target)
213
+ except Failure as exc:
214
+ if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate',
215
+ 'delegation_scope', 'ancestor_inactive', 'not_found',
216
+ 'collaboration_ref_forbidden'}:
217
+ continue
218
+ raise
219
+ record = loads(raw)
220
+ record['effective_status'] = ('expired' if status == 'active' and
221
+ parse_time(expires) <= ctx.now else status)
222
+ if len(records) == limit:
223
+ more = True
224
+ break
225
+ records.append(record)
226
+ return HandlerOutput(data={'items': records,
227
+ 'next_after': records[-1]['id'] if more else None})
228
+
229
+ @op('communication.lease_renew', obj({
230
+ 'id': IDENTIFIER, 'expires_at': STRING,
231
+ 'expected_generation': {'type': 'integer', 'minimum': 1},
232
+ }, ('id', 'expires_at', 'expected_generation')))
233
+ async def lease_renew(ctx, request, tx):
234
+ row, record = await _lease(ctx, request, tx, mutation=True)
235
+ require(row[2] == 'active' and parse_time(row[4]) > ctx.now, 'lease_inactive')
236
+ require(row[3] == request.arguments['expected_generation'], 'generation_conflict')
237
+ expires = parse_time(request.arguments['expires_at'])
238
+ require(ctx.now < expires <= ctx.now + MAX_LEASE_TTL and
239
+ expires > parse_time(row[4]), 'invalid_lease_expiry')
240
+ record.update(expires_at=wire(expires), generation=row[3] + 1)
241
+ updated = tx.execute('UPDATE collaboration_leases SET generation=?,expires_at=?,body=? '
242
+ 'WHERE id=? AND generation=? AND status=? AND expires_at=?',
243
+ (row[3] + 1, wire(expires), canonical(record).decode(),
244
+ request.arguments['id'], row[3], 'active', row[4]), write=True)
245
+ require(updated.rowcount == 1, 'generation_conflict')
246
+ return HandlerOutput(data={'lease': _summary(record)})
247
+
248
+ @op('communication.lease_release', obj({
249
+ 'id': IDENTIFIER, 'expected_generation': {'type': 'integer', 'minimum': 1},
250
+ }, ('id', 'expected_generation')))
251
+ async def lease_release(ctx, request, tx):
252
+ row, record = await _lease(ctx, request, tx, mutation=True)
253
+ require(row[2] == 'active' and parse_time(row[4]) > ctx.now, 'lease_inactive')
254
+ require(row[3] == request.arguments['expected_generation'], 'generation_conflict')
255
+ record.update(status='released', generation=row[3] + 1)
256
+ updated = tx.execute('UPDATE collaboration_leases SET status=?,generation=?,body=? '
257
+ 'WHERE id=? AND status=? AND generation=?',
258
+ ('released', row[3] + 1, canonical(record).decode(),
259
+ request.arguments['id'], 'active', row[3]), write=True)
260
+ require(updated.rowcount == 1, 'generation_conflict')
261
+ return HandlerOutput(data={'lease': _summary(record)})