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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- 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()
|