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/money.py ADDED
@@ -0,0 +1,221 @@
1
+ """Single-currency, append-only clearing for signed subject transfers.
2
+
3
+ The PostgreSQL ledger is authoritative. There is deliberately no public issuer
4
+ operation: root funding and bank administration require a separate local-console
5
+ entry point, and are not reachable through this plugin.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from uuid import uuid4
10
+
11
+ from msg.constants import ROOT_SUBJECT
12
+ from msg.core.codec import canonical, digest, loads, wire
13
+ from msg.core.errors import require
14
+ from msg.core.models import AccessRequirement, HandlerOutput, SignatureProof
15
+ from msg.plugins.common import registration
16
+ from msg.plugins.schemas import IDENTIFIER, obj
17
+
18
+
19
+ CURRENCY_ID = 'primary'
20
+ CODE = 'MSG'
21
+ SCALE = 6
22
+ MAX_MINOR = 2**63 - 1
23
+ POLICY_VERSION = 2
24
+ POLICY_DIGEST = digest({'version': POLICY_VERSION, 'currency_id': CURRENCY_ID,
25
+ 'scale': SCALE, 'transfer_fee': 0, 'allow_overdraft': False,
26
+ 'posting_identity': 'actor/request_id/entry_key'})
27
+
28
+
29
+ async def account_requirements(request, tx):
30
+ """Require the credential's scope to cover its account, even on replay.
31
+
32
+ An operation name in a listing-scoped key is not permission to spend the
33
+ owner's balance or read unrelated private orders. Anonymous calls still
34
+ reach the handler's uniform not-found/signature response.
35
+ """
36
+ if request.proof is None:
37
+ return ()
38
+ key_id = (request.proof.signature.key_id if isinstance(request.proof, SignatureProof)
39
+ else request.proof.credential_id)
40
+ credential = await tx.credential(key_id)
41
+ return (AccessRequirement(resource_id=request.subject or credential.subject_id,
42
+ operation=f'{request.operation}@{request.contract_version}', check='read'),)
43
+
44
+
45
+ def _owner(ctx):
46
+ require(ctx.principal.subject is not None and
47
+ ctx.principal.actor == ctx.principal.subject, 'money_subject_required')
48
+ return ctx.principal.subject
49
+
50
+
51
+ def _amount(value):
52
+ require(type(value) is int and 0 < value <= MAX_MINOR, 'invalid_money_amount')
53
+ return value
54
+
55
+
56
+ def _balance(tx, subject):
57
+ debit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE debit_account=?",
58
+ (subject,))[0]
59
+ credit = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE credit_account=?",
60
+ (subject,))[0]
61
+ return int(credit) - int(debit)
62
+
63
+
64
+ def _supply(tx):
65
+ issued = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='mint'")[0]
66
+ burned = tx.one("SELECT COALESCE(SUM(amount_minor),0) FROM money_ledger WHERE kind='burn'")[0]
67
+ return int(issued) - int(burned)
68
+
69
+
70
+ def clearing_decision(*, kind, sender, recipient, amount, sender_balance,
71
+ recipient_balance, supply, local_issuer=False):
72
+ """Pure policy v2: no clock, UUID, subject privileges or discretionary input."""
73
+ amount = _amount(amount)
74
+ require(all(type(value) is int and 0 <= value <= MAX_MINOR
75
+ for value in (sender_balance, recipient_balance, supply)), 'invalid_money_state')
76
+ require(kind in {'mint', 'burn', 'transfer', 'redeem', 'refund'}, 'invalid_money_kind')
77
+ if kind in {'mint', 'burn'}:
78
+ require(local_issuer, 'root_local_only')
79
+ require((kind == 'mint' and sender is None and recipient == ROOT_SUBJECT) or
80
+ (kind == 'burn' and sender == ROOT_SUBJECT and recipient is None),
81
+ 'invalid_money_accounts')
82
+ else:
83
+ require(sender is not None and recipient is not None, 'invalid_money_accounts')
84
+ require(sender != recipient, 'self_transfer_forbidden')
85
+ if sender in {ROOT_SUBJECT, '@root'}:
86
+ require(local_issuer, 'root_local_only')
87
+ if sender is not None:
88
+ require(sender_balance >= amount, 'insufficient_funds')
89
+ if recipient is not None:
90
+ require(recipient_balance + amount <= MAX_MINOR, 'money_overflow')
91
+ if kind == 'burn':
92
+ require(supply >= amount, 'invalid_money_state')
93
+ if kind == 'mint':
94
+ require(supply + amount <= MAX_MINOR, 'money_overflow')
95
+ return {'kind': kind, 'debit_account': sender, 'credit_account': recipient,
96
+ 'amount_minor': amount, 'currency_id': CURRENCY_ID,
97
+ 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST}
98
+
99
+
100
+ def _post_entry(tx, *, sender, recipient, amount, actor, request_id, now,
101
+ receipt_signer, reference=None, kind='transfer', entry_key='primary',
102
+ _local_issuer=False):
103
+ """Append one named leg in the caller's serialized transaction, never commit.
104
+
105
+ Named legs permit atomic fund+release without manufacturing request IDs.
106
+ Historical receipts remain byte-for-byte untouched by the schema upgrade.
107
+ Only the separate local RootAdmin use case supplies _local_issuer.
108
+ """
109
+ require(not _local_issuer or actor == ROOT_SUBJECT, 'root_local_only')
110
+ clearing_decision(kind=kind, sender=sender, recipient=recipient, amount=amount,
111
+ sender_balance=_balance(tx, sender) if sender else 0,
112
+ recipient_balance=_balance(tx, recipient) if recipient else 0,
113
+ supply=_supply(tx), local_issuer=_local_issuer)
114
+ for account in {sender, recipient} - {None}:
115
+ tx.execute('INSERT INTO money_accounts(subject_id,currency_id) VALUES (?,?) '
116
+ 'ON CONFLICT(subject_id,currency_id) DO NOTHING',
117
+ (account, CURRENCY_ID), write=True)
118
+ transaction_id = 'lt_' + uuid4().hex
119
+ sequence = tx.one("SELECT nextval(pg_get_serial_sequence('money_ledger','seq'))")[0]
120
+ body = {'transaction_id': transaction_id, 'kind': kind, 'currency_id': CURRENCY_ID,
121
+ 'amount_minor': amount, 'from_subject': sender, 'to_subject': recipient,
122
+ 'actor': actor, 'request_id': request_id, 'entry_key': entry_key,
123
+ 'reference': reference, 'committed_at': wire(now),
124
+ 'policy_version': POLICY_VERSION, 'policy_digest': POLICY_DIGEST,
125
+ 'ledger_sequence': sequence}
126
+ receipt = {'body': body,
127
+ 'signature': wire(receipt_signer.sign(canonical(body), purpose='money-receipt'))}
128
+ tx.execute('''INSERT INTO money_ledger
129
+ (seq,id,kind,currency_id,amount_minor,debit_account,credit_account,actor,request_id,
130
+ reference,committed_at,policy_version,policy_digest,receipt,entry_key)
131
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
132
+ (sequence,transaction_id,kind,CURRENCY_ID,amount,sender,recipient,actor,
133
+ request_id,reference,wire(now),POLICY_VERSION,POLICY_DIGEST,
134
+ canonical(receipt).decode(),entry_key), write=True)
135
+ return receipt
136
+
137
+
138
+ def _post_transfer(tx, *, sender, recipient, amount, actor, request_id, now,
139
+ receipt_signer, reference=None, kind='transfer', entry_key='primary',
140
+ escrow_authority=None):
141
+ require(kind in {'transfer', 'redeem', 'refund'}, 'invalid_money_kind')
142
+ account = tx.one('SELECT kind FROM ledger_accounts WHERE id=?', (sender,))
143
+ if account and account[0] == 'order_escrow':
144
+ from msg.market.escrow import _ESCROW_WRITE
145
+ require(escrow_authority is _ESCROW_WRITE, 'escrow_release_forbidden')
146
+ return _post_entry(tx, sender=sender, recipient=recipient, amount=amount,
147
+ actor=actor, request_id=request_id, now=now, receipt_signer=receipt_signer,
148
+ reference=reference, kind=kind, entry_key=entry_key)
149
+
150
+
151
+ def install(app):
152
+ op, finish = registration(app, 'money', ('identity',))
153
+ amount_schema = {'type':'integer','minimum':1,'maximum':MAX_MINOR}
154
+
155
+ @op('money.state', obj(), effect='read')
156
+ async def state(ctx, request, tx):
157
+ return HandlerOutput(data={'currency_id': CURRENCY_ID,
158
+ 'display_name': app.settings.money.display_name,
159
+ 'code': app.settings.money.code, 'scale': SCALE,
160
+ 'total_supply_minor': _supply(tx)})
161
+
162
+ @op('money.banks', obj(), effect='read')
163
+ async def banks(ctx, request, tx):
164
+ rows = tx.rows('SELECT subject_id,status,granted_at,revoked_at '
165
+ 'FROM money_bank_roles ORDER BY subject_id')
166
+ return HandlerOutput(data={'banks':[{'subject_id':r[0], 'status':r[1],
167
+ 'granted_at':r[2], 'revoked_at':r[3]}
168
+ for r in rows]})
169
+
170
+ @op('money.balance', obj(), effect='read', requirements=account_requirements)
171
+ async def balance(ctx, request, tx):
172
+ subject = _owner(ctx)
173
+ return HandlerOutput(data={'subject_id': subject, 'currency_id': CURRENCY_ID,
174
+ 'balance_minor': _balance(tx, subject)})
175
+
176
+ @op('money.ledger', obj({'cursor': {'type':'integer','minimum':0},
177
+ 'limit': {'type':'integer','minimum':1,'maximum':100}}), effect='read', requirements=account_requirements)
178
+ async def ledger(ctx, request, tx):
179
+ subject = _owner(ctx)
180
+ cursor = request.arguments.get('cursor', 0)
181
+ limit = request.arguments.get('limit', 50)
182
+ rows = tx.rows('''SELECT seq,id,kind,amount_minor,debit_account,credit_account,
183
+ committed_at,policy_version,policy_digest,reference,receipt
184
+ FROM money_ledger WHERE seq>? AND
185
+ (debit_account=? OR credit_account=?) ORDER BY seq LIMIT ?''',
186
+ (cursor, subject, subject, limit+1))
187
+ items = [{'ledger_sequence':r[0], 'transaction_id':r[1], 'kind':r[2],
188
+ 'amount_minor':r[3], 'direction':'out' if r[4]==subject else 'in',
189
+ 'counterparty':r[5] if r[4]==subject else r[4],
190
+ 'committed_at':r[6], 'policy_version':r[7],
191
+ 'policy_digest':r[8], 'reference':r[9], 'receipt':loads(r[10])}
192
+ for r in rows[:limit]]
193
+ return HandlerOutput(data={'currency_id':CURRENCY_ID, 'items':items,
194
+ 'next_cursor':rows[limit-1][0] if len(rows)>limit else None})
195
+
196
+ @op('money.transfer', obj({'from_subject': IDENTIFIER, 'to_subject': IDENTIFIER,
197
+ 'currency_id': {'const':CURRENCY_ID},
198
+ 'amount_minor': amount_schema,
199
+ 'reference': {'type':'string','maxLength':160}},
200
+ ('to_subject','currency_id','amount_minor')),
201
+ signature=True, requirements=account_requirements)
202
+ async def transfer(ctx, request, tx):
203
+ args = request.arguments
204
+ require(args.get('from_subject') not in {ROOT_SUBJECT,'@root'}, 'root_local_only')
205
+ sender = _owner(ctx)
206
+ require(args.get('from_subject',sender)==sender, 'money_sender_mismatch')
207
+ source = await tx.subject(sender)
208
+ destination = await tx.subject(args['to_subject'])
209
+ require(source.kind in {'registered','custodial'} and not source.local_only,
210
+ 'money_subject_required')
211
+ require(destination.kind in {'registered','custodial'} and not destination.local_only,
212
+ 'invalid_money_recipient')
213
+ receipt = _post_transfer(tx, sender=sender, recipient=destination.resource_id,
214
+ amount=args['amount_minor'], actor=ctx.principal.actor,
215
+ request_id=request.request_id, now=ctx.now,
216
+ receipt_signer=app.receipt_signer,
217
+ reference=args.get('reference'))
218
+ return HandlerOutput(data={'transfer':receipt,
219
+ 'balance_minor':_balance(tx,sender)})
220
+
221
+ finish()
msg/plugins/offers.py ADDED
@@ -0,0 +1,183 @@
1
+ """Versioned resource offers and atomic, owner-bound purchases.
2
+
3
+ Only installed quota consumers are sellable. Purchases lock the quote; neither
4
+ an offer edit nor a bank role can change the entitlement or bypass permission.
5
+ """
6
+ from datetime import timedelta
7
+
8
+ from msg.constants import ROOT_SUBJECT
9
+ from msg.core.codec import canonical, digest, loads, parse_time, wire
10
+ from msg.core.errors import Failure, require
11
+ from msg.core.models import HandlerOutput
12
+ from msg.plugins.common import new_id, registration
13
+ from msg.plugins.hosting_capacity import ENTITLEMENT_KIND, MAX_CAPACITY_BYTES, extra_capacity
14
+ from msg.plugins.money import account_requirements, CURRENCY_ID, MAX_MINOR, _owner, _balance, _post_transfer
15
+ from msg.plugins.schemas import IDENTIFIER, obj
16
+
17
+ PURCHASE_TTL = 900
18
+ FORBIDDEN_KINDS = frozenset({'user','organization','certificate','csr','delegation',
19
+ 'keystore','identity','capability','admin','system','priority','bank_role'})
20
+
21
+
22
+ def _grant_hosting(app, tx, purchase, now, transaction_id):
23
+ snapshot = purchase['offer_snapshot']
24
+ require(extra_capacity(tx, purchase['subject_id'], now) + purchase['quantity'] +
25
+ app.settings.hosting_base_capacity_bytes <= MAX_CAPACITY_BYTES,
26
+ 'entitlement_capacity_exceeded')
27
+ expiry = (wire(now + timedelta(seconds=snapshot['duration_seconds']))
28
+ if snapshot['duration_seconds'] is not None else None)
29
+ eid = 'ent_' + digest((purchase['subject_id'], purchase['request_id']))[7:39]
30
+ tx.execute('''INSERT INTO resource_entitlements
31
+ (id,subject_id,offer_id,purchase_request_id,quantity,entitlement_kind,
32
+ granted_at,expires_at,redeem_transaction_id) VALUES (?,?,?,?,?,?,?,?,?)''',
33
+ (eid,purchase['subject_id'],snapshot['offer_id'],purchase['request_id'],
34
+ purchase['quantity'],snapshot['entitlement_kind'],wire(now),expiry,transaction_id),write=True)
35
+ return eid
36
+
37
+
38
+ # Finite, trusted code registrations. No evaluator names or code from config.
39
+ ENTITLEMENT_FULFILLERS = {ENTITLEMENT_KIND: ('website', 'byte', 1, _grant_hosting)}
40
+
41
+
42
+ def purchasable(app, resource_kind: str, entitlement_kind: str) -> bool:
43
+ if resource_kind in FORBIDDEN_KINDS:
44
+ return False
45
+ provider = ENTITLEMENT_FULFILLERS.get(entitlement_kind)
46
+ try:
47
+ spec = app.registry.resource_type(resource_kind, 1)
48
+ app.registry.operation('hosting.deploy', 1)
49
+ except Failure:
50
+ return False
51
+ return bool(spec.purchasable and provider and provider[0] == resource_kind)
52
+
53
+
54
+ def validate_local_offer(app, *, resource_kind: str, entitlement_kind: str,
55
+ unit: str, price_minor: int, min_quantity: int,
56
+ max_quantity: int, duration_seconds: int | None = None):
57
+ require(purchasable(app, resource_kind, entitlement_kind), 'resource_not_purchasable')
58
+ require(unit == ENTITLEMENT_FULFILLERS[entitlement_kind][1], 'invalid_offer_unit')
59
+ require(type(price_minor) is int and 0 < price_minor <= MAX_MINOR, 'invalid_offer_price')
60
+ require(type(min_quantity) is int and type(max_quantity) is int and
61
+ 0 < min_quantity <= max_quantity <= MAX_CAPACITY_BYTES and
62
+ price_minor * max_quantity <= MAX_MINOR, 'invalid_offer_quantity')
63
+ require(duration_seconds is None or
64
+ (type(duration_seconds) is int and 0 < duration_seconds <= 315360000),
65
+ 'invalid_offer_duration')
66
+
67
+
68
+ def _public_offer(row):
69
+ return dict(zip(('offer_id','resource_kind','unit','price_minor','min_quantity',
70
+ 'max_quantity','entitlement_kind','duration_seconds','price_revision'),row)) | {
71
+ 'currency_id': CURRENCY_ID, 'provider_version': 1}
72
+
73
+
74
+ def _purchase(tx, purchase_id, subject):
75
+ row = tx.one('''SELECT id,subject_id,request_id,offer_snapshot,quantity,total_minor,
76
+ escrow_account,state,created_at,expires_at,funding_transaction_id,
77
+ final_transaction_id,entitlement_id,reason FROM money_purchases WHERE id=? AND subject_id=?''',
78
+ (purchase_id,subject))
79
+ require(row is not None, 'purchase_not_found')
80
+ result = dict(zip(('id','subject_id','request_id','offer_snapshot','quantity','total_minor',
81
+ 'escrow_account','state','created_at','expires_at','funding_transaction_id',
82
+ 'final_transaction_id','entitlement_id','reason'),row))
83
+ result['offer_snapshot'] = loads(result['offer_snapshot'])
84
+ return result
85
+
86
+
87
+ def _finalize(app, tx, purchase, now, request_id, *, cancel=False):
88
+ require(purchase['state'] == 'pending', 'purchase_not_pending')
89
+ snapshot = purchase['offer_snapshot']
90
+ provider = ENTITLEMENT_FULFILLERS.get(snapshot['entitlement_kind'])
91
+ reason = ('cancelled' if cancel else 'expired' if parse_time(purchase['expires_at']) <= now
92
+ else 'provider_unavailable' if not purchasable(app,snapshot['resource_kind'],snapshot['entitlement_kind'])
93
+ or provider[2] != snapshot['provider_version'] else None)
94
+ require(_balance(tx,purchase['escrow_account']) == purchase['total_minor'], 'escrow_balance_mismatch')
95
+ receipt = _post_transfer(tx,sender=purchase['escrow_account'],
96
+ recipient=purchase['subject_id'] if reason else ROOT_SUBJECT,
97
+ amount=purchase['total_minor'],actor=purchase['subject_id'],request_id=request_id,
98
+ now=now,receipt_signer=app.receipt_signer,kind='refund' if reason else 'redeem',
99
+ reference='purchase_' + ('refund:' if reason else 'settle:') + purchase['id'],
100
+ entry_key='purchase_final')
101
+ tid = receipt['body']['transaction_id']
102
+ eid = None if reason else provider[3](app,tx,purchase,now,tid)
103
+ tx.execute('''UPDATE money_purchases SET state=?,final_transaction_id=?,entitlement_id=?,reason=?
104
+ WHERE id=? AND state='pending' ''',
105
+ ('refunded' if reason else 'settled',tid,eid,reason,purchase['id']),write=True)
106
+ return receipt
107
+
108
+
109
+ def install(app):
110
+ op,finish = registration(app,'offers',('money',))
111
+
112
+ @op('money.offers',obj(),effect='read')
113
+ async def offers(ctx,request,tx):
114
+ rows = tx.rows('''SELECT offer_id,resource_kind,unit,price_minor,min_quantity,
115
+ max_quantity,entitlement_kind,duration_seconds,price_revision
116
+ FROM server_offers WHERE enabled=TRUE ORDER BY offer_id''')
117
+ return HandlerOutput(data={'currency_id':CURRENCY_ID,'offers':[
118
+ _public_offer(row) for row in rows if purchasable(app,row[1],row[6])]})
119
+
120
+ @op('money.redeem',obj({'offer_id':IDENTIFIER,'quantity':{'type':'integer','minimum':1,'maximum':MAX_MINOR},
121
+ 'currency_id':{'const':CURRENCY_ID},'price_revision':IDENTIFIER},
122
+ ('offer_id','quantity','currency_id','price_revision')),signature=True, requirements=account_requirements)
123
+ @op('money.redeem',obj({'offer_id':IDENTIFIER,'quantity':{'type':'integer','minimum':1,'maximum':MAX_MINOR},
124
+ 'currency_id':{'const':CURRENCY_ID},'price_revision':IDENTIFIER,'defer':{'type':'boolean'}},
125
+ ('offer_id','quantity','currency_id','price_revision')),signature=True, version=2, requirements=account_requirements)
126
+ async def redeem(ctx,request,tx):
127
+ owner = _owner(ctx)
128
+ args = request.arguments
129
+ row = tx.one('''SELECT offer_id,resource_kind,unit,price_minor,min_quantity,
130
+ max_quantity,entitlement_kind,duration_seconds,price_revision FROM server_offers
131
+ WHERE offer_id=? AND enabled=TRUE''',(args['offer_id'],))
132
+ require(row is not None and purchasable(app,row[1],row[6]),'offer_not_found')
133
+ require(args['price_revision'] == row[8],'offer_price_changed')
134
+ require(row[4] <= args['quantity'] <= row[5] and row[3]*args['quantity'] <= MAX_MINOR,
135
+ 'invalid_offer_quantity')
136
+ require(extra_capacity(tx,owner,ctx.now) + args['quantity'] + app.settings.hosting_base_capacity_bytes
137
+ <= MAX_CAPACITY_BYTES,'entitlement_capacity_exceeded')
138
+ purchase_id = new_id('pur')
139
+ escrow = new_id('esc')
140
+ tx.execute("INSERT INTO ledger_accounts(id,kind,subject_id,source_id) VALUES (?,'purchase_escrow',NULL,?)",
141
+ (escrow,purchase_id),write=True)
142
+ funding = _post_transfer(tx,sender=owner,recipient=escrow,amount=row[3]*args['quantity'],
143
+ actor=owner,request_id=request.request_id,now=ctx.now,receipt_signer=app.receipt_signer,
144
+ reference='purchase_fund:'+purchase_id,entry_key='purchase_fund')
145
+ tx.execute('''INSERT INTO money_purchases
146
+ (id,subject_id,request_id,offer_snapshot,quantity,total_minor,escrow_account,state,
147
+ created_at,expires_at,funding_transaction_id) VALUES (?,?,?,?,?,?,?,'pending',?,?,?)''',
148
+ (purchase_id,owner,request.request_id,canonical(_public_offer(row)).decode(),args['quantity'],
149
+ row[3]*args['quantity'],escrow,wire(ctx.now),wire(ctx.now+timedelta(seconds=PURCHASE_TTL)),
150
+ funding['body']['transaction_id']),write=True)
151
+ settlement = None
152
+ if not args.get('defer',False):
153
+ settlement = _finalize(app,tx,_purchase(tx,purchase_id,owner),ctx.now,request.request_id)
154
+ return HandlerOutput(data={'purchase':_purchase(tx,purchase_id,owner),
155
+ 'funding':funding,'settlement':settlement})
156
+
157
+ @op('money.purchase_get',obj({'purchase_id':IDENTIFIER},('purchase_id',)),effect='read', requirements=account_requirements)
158
+ async def get(ctx,request,tx):
159
+ return HandlerOutput(data={'purchase':_purchase(tx,request.arguments['purchase_id'],_owner(ctx))})
160
+
161
+ @op('money.purchase_settle',obj({'purchase_id':IDENTIFIER},('purchase_id',)),signature=True, requirements=account_requirements)
162
+ async def settle(ctx,request,tx):
163
+ owner = _owner(ctx)
164
+ purchase = _purchase(tx,request.arguments['purchase_id'],owner)
165
+ receipt = _finalize(app,tx,purchase,ctx.now,request.request_id)
166
+ return HandlerOutput(data={'purchase':_purchase(tx,purchase['id'],owner),'settlement':receipt})
167
+
168
+ @op('money.purchase_cancel',obj({'purchase_id':IDENTIFIER},('purchase_id',)),signature=True, requirements=account_requirements)
169
+ async def cancel(ctx,request,tx):
170
+ owner = _owner(ctx)
171
+ purchase = _purchase(tx,request.arguments['purchase_id'],owner)
172
+ receipt = _finalize(app,tx,purchase,ctx.now,request.request_id,cancel=True)
173
+ return HandlerOutput(data={'purchase':_purchase(tx,purchase['id'],owner),'refund':receipt})
174
+
175
+ @op('money.entitlements',obj(),effect='read', requirements=account_requirements)
176
+ async def entitlements(ctx,request,tx):
177
+ owner = _owner(ctx)
178
+ rows = tx.rows('''SELECT id,offer_id,quantity,entitlement_kind,granted_at,expires_at,
179
+ redeem_transaction_id FROM resource_entitlements WHERE subject_id=? ORDER BY id''',(owner,))
180
+ return HandlerOutput(data={'entitlements':[dict(zip(('id','offer_id','quantity','entitlement_kind',
181
+ 'granted_at','expires_at','transaction_id'),row)) for row in rows],
182
+ 'hosting_extra_bytes':extra_capacity(tx,owner,ctx.now)})
183
+ finish()