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/admin/money.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Physical-console central bank operations. Never register these as network ops."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import getpass
|
|
6
|
+
import re
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from msg.admin.root import require_local_console, root_envelope
|
|
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 AuditEvent, Event, ResourceRef
|
|
15
|
+
from msg.plugins.money import (
|
|
16
|
+
CURRENCY_ID,
|
|
17
|
+
MAX_MINOR,
|
|
18
|
+
SCALE,
|
|
19
|
+
_balance,
|
|
20
|
+
_post_entry,
|
|
21
|
+
_supply,
|
|
22
|
+
)
|
|
23
|
+
from msg.plugins.offers import validate_local_offer
|
|
24
|
+
from msg.security.crypto import Ed25519Signer, open_private_key
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_amount(value: str) -> int:
|
|
28
|
+
"""Parse a displayed MSG amount without binary floats or silent rounding."""
|
|
29
|
+
require(isinstance(value, str) and re.fullmatch(r'(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?', value),
|
|
30
|
+
'invalid_money_amount')
|
|
31
|
+
minor = int(Decimal(value) * (10 ** SCALE))
|
|
32
|
+
require(0 < minor <= MAX_MINOR, 'invalid_money_amount')
|
|
33
|
+
return minor
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _root_signer(app, signer):
|
|
37
|
+
require(isinstance(signer, Ed25519Signer) and
|
|
38
|
+
signer.public_key == app.certificates.root_public_key, 'root_key_mismatch')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_OFFER_COLUMNS = ('offer_id', 'resource_kind', 'unit', 'price_minor', 'min_quantity',
|
|
42
|
+
'max_quantity', 'entitlement_kind', 'duration_seconds', 'enabled',
|
|
43
|
+
'price_revision')
|
|
44
|
+
_UNSET = object()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _offer_snapshot(tx, offer_id):
|
|
48
|
+
row = tx.one('''SELECT offer_id,resource_kind,unit,price_minor,min_quantity,max_quantity,
|
|
49
|
+
entitlement_kind,duration_seconds,enabled,price_revision
|
|
50
|
+
FROM server_offers WHERE offer_id=?''', (offer_id,))
|
|
51
|
+
return dict(zip(_OFFER_COLUMNS, row)) if row else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def apply_offer(app, signer, *, action: str, operator: str, offer_id: str,
|
|
55
|
+
fields: dict | None = None, expected_offer=_UNSET):
|
|
56
|
+
"""Root-signed local offer mutation; set remains Registry/fulfiller gated."""
|
|
57
|
+
_root_signer(app, signer)
|
|
58
|
+
require(action in {'set', 'disable'}, 'invalid_offer_action')
|
|
59
|
+
require(isinstance(offer_id, str) and
|
|
60
|
+
re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]{0,79}', offer_id), 'invalid_offer_id')
|
|
61
|
+
if action == 'set':
|
|
62
|
+
require(isinstance(fields, dict) and set(fields) == {
|
|
63
|
+
'resource_kind', 'unit', 'price_minor', 'min_quantity',
|
|
64
|
+
'max_quantity', 'entitlement_kind', 'duration_seconds'}, 'invalid_offer_fields')
|
|
65
|
+
validate_local_offer(app, **fields)
|
|
66
|
+
else:
|
|
67
|
+
require(fields is None, 'invalid_offer_fields')
|
|
68
|
+
now = app.clock()
|
|
69
|
+
request_id = 'local_offer_' + uuid4().hex
|
|
70
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
71
|
+
previous = _offer_snapshot(tx, offer_id)
|
|
72
|
+
if expected_offer is not _UNSET:
|
|
73
|
+
require(previous == expected_offer, 'offer_preview_stale')
|
|
74
|
+
if action == 'set':
|
|
75
|
+
validate_local_offer(app, **fields)
|
|
76
|
+
revision = 'price_' + uuid4().hex
|
|
77
|
+
tx.execute('''INSERT INTO server_offers
|
|
78
|
+
(offer_id,resource_kind,unit,price_minor,min_quantity,max_quantity,
|
|
79
|
+
entitlement_kind,duration_seconds,enabled,price_revision)
|
|
80
|
+
VALUES (?,?,?,?,?,?,?,?,TRUE,?)
|
|
81
|
+
ON CONFLICT(offer_id) DO UPDATE SET
|
|
82
|
+
resource_kind=excluded.resource_kind,unit=excluded.unit,
|
|
83
|
+
price_minor=excluded.price_minor,min_quantity=excluded.min_quantity,
|
|
84
|
+
max_quantity=excluded.max_quantity,entitlement_kind=excluded.entitlement_kind,
|
|
85
|
+
duration_seconds=excluded.duration_seconds,enabled=TRUE,
|
|
86
|
+
price_revision=excluded.price_revision''',
|
|
87
|
+
(offer_id, fields['resource_kind'], fields['unit'], fields['price_minor'],
|
|
88
|
+
fields['min_quantity'], fields['max_quantity'], fields['entitlement_kind'],
|
|
89
|
+
fields['duration_seconds'], revision), write=True)
|
|
90
|
+
else:
|
|
91
|
+
require(previous is not None, 'offer_not_found')
|
|
92
|
+
require(previous['enabled'], 'offer_already_disabled')
|
|
93
|
+
tx.execute('UPDATE server_offers SET enabled=FALSE WHERE offer_id=?',
|
|
94
|
+
(offer_id,), write=True)
|
|
95
|
+
current = _offer_snapshot(tx, offer_id)
|
|
96
|
+
statement = {'action': 'offer_' + action, 'offer_id': offer_id,
|
|
97
|
+
'before': previous, 'after': current, 'operator': operator,
|
|
98
|
+
'request_id': request_id, 'time': wire(now)}
|
|
99
|
+
signature = wire(signer.sign(canonical(statement), purpose='money-admin'))
|
|
100
|
+
event = Event(id='audit_' + uuid4().hex, type='root.money.offer.' + action,
|
|
101
|
+
time=now, request_id=request_id, actor=ROOT_SUBJECT,
|
|
102
|
+
subject=ROOT_SUBJECT, resources=(ResourceRef(id=ROOT_SUBJECT),),
|
|
103
|
+
data={**statement, 'root_signature': signature})
|
|
104
|
+
await tx.append_event(event)
|
|
105
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
106
|
+
authority=(ResourceRef(id=app.certificates.root_certificate.resource_id),),
|
|
107
|
+
before_digest=digest(previous), after_digest=digest(current),
|
|
108
|
+
previous_digest=None, entry_digest='', result='offer_' + action))
|
|
109
|
+
return {'action': action, 'offer': current, 'audit_event_id': event.id}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def apply_money(app, signer, *, action: str, operator: str, amount_minor: int | None = None,
|
|
113
|
+
subject_id: str | None = None, expected_state: dict | None = None):
|
|
114
|
+
"""Internal local use case; callers must establish the OS-console boundary.
|
|
115
|
+
|
|
116
|
+
Each invocation creates a fresh local request id. PG serializes all writers,
|
|
117
|
+
so balance and supply checks are re-evaluated within the committing transaction.
|
|
118
|
+
"""
|
|
119
|
+
_root_signer(app, signer)
|
|
120
|
+
require(action in {'mint', 'burn', 'transfer', 'bank_add', 'bank_remove', 'bank_fund'}, 'invalid_money_action')
|
|
121
|
+
if action in {'mint', 'burn', 'transfer', 'bank_fund'}:
|
|
122
|
+
require(type(amount_minor) is int and 0 < amount_minor <= MAX_MINOR, 'invalid_money_amount')
|
|
123
|
+
else:
|
|
124
|
+
require(amount_minor is None, 'invalid_money_amount')
|
|
125
|
+
if action in {'transfer', 'bank_add', 'bank_remove', 'bank_fund'}:
|
|
126
|
+
require(isinstance(subject_id, str) and subject_id not in {ROOT_SUBJECT, '@root'},
|
|
127
|
+
'invalid_money_recipient')
|
|
128
|
+
else:
|
|
129
|
+
require(subject_id is None, 'invalid_money_recipient')
|
|
130
|
+
|
|
131
|
+
request_id = 'local_money_' + uuid4().hex
|
|
132
|
+
now = app.clock()
|
|
133
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
134
|
+
if subject_id is not None:
|
|
135
|
+
subject = await tx.subject(subject_id)
|
|
136
|
+
require(subject.kind in {'registered', 'custodial'} and not subject.local_only,
|
|
137
|
+
'invalid_money_recipient')
|
|
138
|
+
before_supply = _supply(tx)
|
|
139
|
+
before_root = _balance(tx, ROOT_SUBJECT)
|
|
140
|
+
row = (tx.one('SELECT status FROM money_bank_roles WHERE subject_id=?', (subject_id,))
|
|
141
|
+
if action.startswith('bank_') else None)
|
|
142
|
+
before_role = row[0] if row else None
|
|
143
|
+
if expected_state is not None:
|
|
144
|
+
current = {'supply': before_supply, 'root': before_root,
|
|
145
|
+
'subject': _balance(tx, subject_id) if subject_id else None,
|
|
146
|
+
'role': before_role}
|
|
147
|
+
require(current == expected_state, 'money_preview_stale')
|
|
148
|
+
if action.startswith('bank_'):
|
|
149
|
+
require(action == 'bank_fund' or
|
|
150
|
+
(action == 'bank_add' and before_role != 'active') or
|
|
151
|
+
(action == 'bank_remove' and before_role == 'active'), 'bank_role_unchanged')
|
|
152
|
+
if action in {'bank_add', 'bank_fund'}:
|
|
153
|
+
tx.execute('''INSERT INTO money_bank_roles(subject_id,status,granted_at,granted_by,revoked_at)
|
|
154
|
+
VALUES (?,?,?,?,NULL) ON CONFLICT(subject_id) DO UPDATE SET
|
|
155
|
+
status='active',granted_at=excluded.granted_at,granted_by=excluded.granted_by,
|
|
156
|
+
revoked_at=NULL''', (subject_id, 'active', wire(now), ROOT_SUBJECT), write=True)
|
|
157
|
+
else:
|
|
158
|
+
tx.execute("UPDATE money_bank_roles SET status='revoked',revoked_at=? WHERE subject_id=?",
|
|
159
|
+
(wire(now), subject_id), write=True)
|
|
160
|
+
receipt = None
|
|
161
|
+
if action not in {'bank_add', 'bank_remove'}:
|
|
162
|
+
if action == 'mint':
|
|
163
|
+
require(before_supply + amount_minor <= MAX_MINOR and
|
|
164
|
+
before_root + amount_minor <= MAX_MINOR, 'money_overflow')
|
|
165
|
+
debit, credit, kind = None, ROOT_SUBJECT, 'mint'
|
|
166
|
+
elif action == 'burn':
|
|
167
|
+
require(before_root >= amount_minor, 'insufficient_funds')
|
|
168
|
+
debit, credit, kind = ROOT_SUBJECT, None, 'burn'
|
|
169
|
+
else:
|
|
170
|
+
require(before_root >= amount_minor, 'insufficient_funds')
|
|
171
|
+
require(_balance(tx, subject_id) + amount_minor <= MAX_MINOR, 'money_overflow')
|
|
172
|
+
debit, credit, kind = ROOT_SUBJECT, subject_id, 'transfer'
|
|
173
|
+
receipt = _post_entry(tx, sender=debit, recipient=credit,
|
|
174
|
+
amount=amount_minor, actor=ROOT_SUBJECT, request_id=request_id,
|
|
175
|
+
now=now, receipt_signer=app.receipt_signer, kind=kind, _local_issuer=True)
|
|
176
|
+
statement = {'action': action, 'subject_id': subject_id, 'amount_minor': amount_minor,
|
|
177
|
+
'currency_id': CURRENCY_ID, 'operator': operator, 'request_id': request_id,
|
|
178
|
+
'time': wire(now), 'receipt_digest': digest(receipt) if receipt else None,
|
|
179
|
+
'before_supply_minor': before_supply, 'after_supply_minor': _supply(tx),
|
|
180
|
+
'before_root_minor': before_root, 'after_root_minor': _balance(tx, ROOT_SUBJECT),
|
|
181
|
+
'before_role': before_role}
|
|
182
|
+
audit_ids = []
|
|
183
|
+
actions = ('bank_add', 'transfer') if action == 'bank_fund' else (action,)
|
|
184
|
+
for audit_action in actions:
|
|
185
|
+
fact = {**statement, 'action': audit_action, 'combined_action': action}
|
|
186
|
+
proof = wire(signer.sign(canonical(fact), purpose='money-admin'))
|
|
187
|
+
event = Event(id='audit_' + uuid4().hex, type='root.money.' + audit_action,
|
|
188
|
+
time=now, request_id=request_id, actor=ROOT_SUBJECT,
|
|
189
|
+
subject=ROOT_SUBJECT,
|
|
190
|
+
resources=(ResourceRef(id=subject_id or ROOT_SUBJECT),),
|
|
191
|
+
data={**fact, 'root_signature': proof})
|
|
192
|
+
await tx.append_event(event)
|
|
193
|
+
await tx.append_audit(AuditEvent(event=event,
|
|
194
|
+
authority=(ResourceRef(id=app.certificates.root_certificate.resource_id),),
|
|
195
|
+
before_digest=digest({'supply': before_supply, 'root': before_root, 'role': before_role}),
|
|
196
|
+
after_digest=digest(fact), previous_digest=None, entry_digest='', result=audit_action))
|
|
197
|
+
audit_ids.append(event.id)
|
|
198
|
+
return {'action': action, 'receipt': receipt, 'audit_event_id': audit_ids[-1],
|
|
199
|
+
'audit_event_ids': audit_ids,
|
|
200
|
+
'root_balance_minor': statement['after_root_minor'],
|
|
201
|
+
'total_supply_minor': statement['after_supply_minor'],
|
|
202
|
+
'subject_id': subject_id}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _confirmed_money(app, action, *, operator, amount, subject_id,
|
|
207
|
+
emit, confirm, unlock):
|
|
208
|
+
"""The command's approval pipeline, shared with an isolated TestConsole.
|
|
209
|
+
|
|
210
|
+
The production caller must establish the physical-console boundary first.
|
|
211
|
+
There is no network operation, config option or command flag selecting IO
|
|
212
|
+
adapters. The diagnostic replaces only IO/unlock on its fresh Test Root.
|
|
213
|
+
"""
|
|
214
|
+
require(action in {'mint','burn','transfer','bank_add','bank_remove','bank_fund'},
|
|
215
|
+
'invalid_money_action')
|
|
216
|
+
amount_minor = parse_amount(amount) if amount is not None else None
|
|
217
|
+
resolved_subject = subject_id
|
|
218
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
219
|
+
if subject_id and subject_id.startswith('@'):
|
|
220
|
+
namespace = (await tx.path(app.namespace_root)).rstrip('/')
|
|
221
|
+
resolved_subject = await tx.resolve(namespace + '/' + subject_id)
|
|
222
|
+
if resolved_subject is not None:
|
|
223
|
+
subject = await tx.subject(resolved_subject)
|
|
224
|
+
require(subject.kind in {'registered','custodial'} and not subject.local_only,
|
|
225
|
+
'invalid_money_recipient')
|
|
226
|
+
root_before = _balance(tx, ROOT_SUBJECT)
|
|
227
|
+
supply_before = _supply(tx)
|
|
228
|
+
subject_before = _balance(tx, resolved_subject) if resolved_subject else None
|
|
229
|
+
role_row = (tx.one('SELECT status FROM money_bank_roles WHERE subject_id=?',
|
|
230
|
+
(resolved_subject,)) if action.startswith('bank_') else None)
|
|
231
|
+
role_before = role_row[0] if role_row else None
|
|
232
|
+
root_delta = (amount_minor if action == 'mint' else
|
|
233
|
+
-amount_minor if action in {'burn','transfer','bank_fund'} else 0)
|
|
234
|
+
supply_delta = (amount_minor if action == 'mint' else
|
|
235
|
+
-amount_minor if action == 'burn' else 0)
|
|
236
|
+
preview = {'action':action, 'amount_minor':amount_minor, 'currency_id':CURRENCY_ID,
|
|
237
|
+
'subject_id':resolved_subject,
|
|
238
|
+
'subject_handle':subject_id if subject_id != resolved_subject else None,
|
|
239
|
+
'root_balance_before_minor':root_before, 'root_balance_after_minor':root_before+root_delta,
|
|
240
|
+
'total_supply_before_minor':supply_before, 'total_supply_after_minor':supply_before+supply_delta,
|
|
241
|
+
'recipient_balance_before_minor':subject_before,
|
|
242
|
+
'recipient_balance_after_minor':subject_before+amount_minor
|
|
243
|
+
if action in {'transfer','bank_fund'} else subject_before,
|
|
244
|
+
'bank_role_before':role_before, 'bank_role_after':'active'
|
|
245
|
+
if action in {'bank_add','bank_fund'} else 'revoked' if action=='bank_remove' else role_before}
|
|
246
|
+
# Distinct confirmations, bound to one before/after snapshot. Unlock occurs
|
|
247
|
+
# only after BOTH role and funding approvals, before the atomic use case.
|
|
248
|
+
approvals = ('bank_add','transfer') if action == 'bank_fund' else (action,)
|
|
249
|
+
for approved_action in approvals:
|
|
250
|
+
part = {**preview, 'approval_for':approved_action}
|
|
251
|
+
emit(canonical(part).decode()) # Escaped JSON, never terminal control text.
|
|
252
|
+
approval = 'CONFIRM MONEY ' + digest(part)
|
|
253
|
+
require(confirm('Type ' + approval + ' to continue: ') == approval, 'approval_cancelled')
|
|
254
|
+
signer = unlock()
|
|
255
|
+
return await apply_money(app, signer, action=action, operator=operator,
|
|
256
|
+
amount_minor=amount_minor, subject_id=resolved_subject,
|
|
257
|
+
expected_state={'supply':supply_before, 'root':root_before,
|
|
258
|
+
'subject':subject_before, 'role':role_before})
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class MoneyAdmin:
|
|
262
|
+
def __init__(self, config_dir):
|
|
263
|
+
self.config_dir = config_dir
|
|
264
|
+
|
|
265
|
+
def execute(self, action, *, amount=None, subject_id=None):
|
|
266
|
+
operator = require_local_console(self.config_dir)
|
|
267
|
+
from msg.admin.root import RootAdmin
|
|
268
|
+
app = RootAdmin(self.config_dir)._app()
|
|
269
|
+
|
|
270
|
+
def unlock():
|
|
271
|
+
pin = getpass.getpass('Root PIN/passphrase: ')
|
|
272
|
+
private = open_private_key(loads(root_envelope(self.config_dir).read_bytes()), pin)
|
|
273
|
+
return Ed25519Signer.from_bytes(private)
|
|
274
|
+
|
|
275
|
+
async def run():
|
|
276
|
+
await app.load()
|
|
277
|
+
try:
|
|
278
|
+
return await _confirmed_money(app, action, operator=operator, amount=amount,
|
|
279
|
+
subject_id=subject_id, emit=print, confirm=input, unlock=unlock)
|
|
280
|
+
finally:
|
|
281
|
+
await app.close()
|
|
282
|
+
return asyncio.run(run())
|
|
283
|
+
|
|
284
|
+
def execute_offer(self, action, *, offer_id, fields=None):
|
|
285
|
+
operator = require_local_console(self.config_dir)
|
|
286
|
+
require(action in {'set', 'disable'}, 'invalid_offer_action')
|
|
287
|
+
require(isinstance(offer_id, str) and
|
|
288
|
+
re.fullmatch(r'[a-zA-Z][a-zA-Z0-9_-]{0,79}', offer_id), 'invalid_offer_id')
|
|
289
|
+
from msg.admin.root import RootAdmin
|
|
290
|
+
app = RootAdmin(self.config_dir)._app()
|
|
291
|
+
|
|
292
|
+
async def run():
|
|
293
|
+
await app.load()
|
|
294
|
+
try:
|
|
295
|
+
if action == 'set':
|
|
296
|
+
require(isinstance(fields, dict) and set(fields) == {
|
|
297
|
+
'resource_kind', 'unit', 'price', 'min_quantity',
|
|
298
|
+
'max_quantity', 'entitlement_kind', 'duration_seconds'},
|
|
299
|
+
'invalid_offer_fields')
|
|
300
|
+
fields['price_minor'] = parse_amount(fields.pop('price'))
|
|
301
|
+
validate_local_offer(app, **fields)
|
|
302
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
303
|
+
previous = _offer_snapshot(tx, offer_id)
|
|
304
|
+
if action == 'disable':
|
|
305
|
+
require(previous is not None, 'offer_not_found')
|
|
306
|
+
require(previous['enabled'], 'offer_already_disabled')
|
|
307
|
+
preview = {'action': 'offer_' + action, 'offer_id': offer_id,
|
|
308
|
+
'before': previous, 'proposed': fields if action == 'set' else
|
|
309
|
+
{**previous, 'enabled': False}}
|
|
310
|
+
print(canonical(preview).decode())
|
|
311
|
+
approval = 'CONFIRM MONEY ' + digest(preview)
|
|
312
|
+
require(input('Type ' + approval + ' to continue: ') == approval,
|
|
313
|
+
'approval_cancelled')
|
|
314
|
+
pin = getpass.getpass('Root PIN/passphrase: ')
|
|
315
|
+
private = open_private_key(loads(root_envelope(self.config_dir).read_bytes()), pin)
|
|
316
|
+
signer = Ed25519Signer.from_bytes(private)
|
|
317
|
+
return await apply_offer(app, signer, action=action, operator=operator,
|
|
318
|
+
offer_id=offer_id, fields=fields,
|
|
319
|
+
expected_offer=previous)
|
|
320
|
+
finally:
|
|
321
|
+
await app.close()
|
|
322
|
+
return asyncio.run(run())
|
msg/admin/preflight.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Read-only installation/legacy-ledger inventory, not production acceptance.
|
|
2
|
+
|
|
3
|
+
Unlike opening Application/initializing a metadata store, this command performs
|
|
4
|
+
no DDL, migration, filesystem repair, Root unlock, effect emission or sequence
|
|
5
|
+
allocation. It never supplies missing operational evidence with fixture results.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
from msg.config import load_settings
|
|
17
|
+
from msg.core.codec import canonical, decode, digest, loads
|
|
18
|
+
from msg.core.errors import Failure, require
|
|
19
|
+
from msg.core.models import Certificate
|
|
20
|
+
|
|
21
|
+
_FIELD_EVIDENCE = (
|
|
22
|
+
('installation_identity', '#64/#65', 'deployed source/configuration and authorized inventory'),
|
|
23
|
+
('ingress_log_chain', '#64', 'CDN/proxy/APM/nginx/service redaction and listener evidence'),
|
|
24
|
+
('protected_snapshot', '#65/#84', 'independent provenance and digest of the field backup'),
|
|
25
|
+
('restore_conservation', '#65/#84', 'isolated old/new ledger and signed-history comparison'),
|
|
26
|
+
('current_checkpoint', '#69', 'independently current signed revocation head outside the rollback set'),
|
|
27
|
+
('physical_console', '#70', 'real authorized local VT/serial console and rejection evidence'),
|
|
28
|
+
('ca_transition', '#70', 'finite old CA inventory and individually approved reissue/revoke'),
|
|
29
|
+
('bank_fund_boundary', '#70/B', 'joint role versus injection gate evidence; no actual funding here'),
|
|
30
|
+
('capacity_and_failure_drill', '#84', 'measured capacity and isolated install/crash/rollback drill'),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inspect_database(connection):
|
|
35
|
+
require(connection.execute('SHOW transaction_read_only').fetchone() == ('on',),
|
|
36
|
+
'preflight_read_only_required')
|
|
37
|
+
tables = {row[0] for row in connection.execute(
|
|
38
|
+
'SELECT tablename FROM pg_tables WHERE schemaname=current_schema()')}
|
|
39
|
+
result = {'read_only': True, 'state': 'empty' if not tables else 'unclassified',
|
|
40
|
+
'table_count': len(tables), 'server_version': connection.info.server_version}
|
|
41
|
+
if not tables:
|
|
42
|
+
return result
|
|
43
|
+
required = {'money_ledger', 'money_accounts', 'store_orders', 'bounty_listings', 'identities'}
|
|
44
|
+
if required <= tables:
|
|
45
|
+
from msg.storage.ledger_migration import (
|
|
46
|
+
_escrow_sources,
|
|
47
|
+
_validate_identity_references,
|
|
48
|
+
_validate_ledger,
|
|
49
|
+
)
|
|
50
|
+
escrow = _escrow_sources(connection)
|
|
51
|
+
legacy = sum(connection.execute('SELECT 1 FROM identities WHERE id=%s', (key,)).fetchone()
|
|
52
|
+
is not None for key in escrow)
|
|
53
|
+
ledger = {'escrow_accounts': len(escrow), 'legacy_escrow_identities': legacy,
|
|
54
|
+
'checks': 'blocked', 'migration_performed': False}
|
|
55
|
+
result['state'] = 'typed_ledger' if 'ledger_accounts' in tables else 'legacy_ledger'
|
|
56
|
+
try:
|
|
57
|
+
_validate_identity_references(connection, escrow)
|
|
58
|
+
if 'ledger_accounts' in tables:
|
|
59
|
+
_validate_ledger(connection)
|
|
60
|
+
ledger['checks'] = 'passed'
|
|
61
|
+
else:
|
|
62
|
+
ledger['reason'] = 'isolated_typed_account_migration_required'
|
|
63
|
+
except RuntimeError:
|
|
64
|
+
ledger['reason'] = 'legacy_reference_or_conservation_review_required'
|
|
65
|
+
# Preserve exact text cells, including historical receipts: do not decode
|
|
66
|
+
# or re-sign them. A server cursor bounds memory for retained ledgers.
|
|
67
|
+
hasher = hashlib.sha256()
|
|
68
|
+
count = 0
|
|
69
|
+
with connection.cursor(name='msg_preflight_ledger') as rows:
|
|
70
|
+
rows.itersize = 256
|
|
71
|
+
rows.execute('SELECT * FROM money_ledger ORDER BY seq')
|
|
72
|
+
for row in rows:
|
|
73
|
+
raw = canonical(row)
|
|
74
|
+
hasher.update(len(raw).to_bytes(8, 'big'))
|
|
75
|
+
hasher.update(raw)
|
|
76
|
+
count += 1
|
|
77
|
+
ledger.update(rows=count, history_digest='sha256:' + hasher.hexdigest())
|
|
78
|
+
result['ledger'] = ledger
|
|
79
|
+
if 'settings' in tables:
|
|
80
|
+
gate = connection.execute("SELECT value FROM settings WHERE key='recovery_quarantine'").fetchone()
|
|
81
|
+
result['quarantine_present'] = gate is not None
|
|
82
|
+
row = connection.execute("SELECT value FROM settings WHERE key='active_root_certificate'").fetchone()
|
|
83
|
+
result['active_root_certificate'] = loads(row[0]) if row else None
|
|
84
|
+
if 'certificates' in tables:
|
|
85
|
+
inventory = []
|
|
86
|
+
for identifier, subject, parent, revoked, body in connection.execute(
|
|
87
|
+
'SELECT id,subject,parent,revoked,body FROM certificates ORDER BY id'):
|
|
88
|
+
certificate = decode(Certificate, loads(body))
|
|
89
|
+
require((certificate.resource_id, certificate.subject_id, certificate.parent_certificate_id) ==
|
|
90
|
+
(identifier, subject, parent), 'preflight_certificate_mismatch')
|
|
91
|
+
if certificate.kind != 'ca':
|
|
92
|
+
continue
|
|
93
|
+
policy = certificate.issuance
|
|
94
|
+
grants = (*certificate.grants, *(policy.issue_grants if policy is not None else ()))
|
|
95
|
+
operations = sorted({operation for grant in grants for operation in grant.operations})
|
|
96
|
+
inventory.append({'id': identifier, 'subject': subject, 'parent': parent,
|
|
97
|
+
'revoked': bool(revoked), 'key_id': certificate.key_id,
|
|
98
|
+
'signed_grants_digest': digest(certificate.grants),
|
|
99
|
+
'issuance_digest': digest(policy), 'operation_count': len(operations),
|
|
100
|
+
'explicit_operations': all('*' not in operation for operation in operations),
|
|
101
|
+
'max_child_ca_depth': policy.max_child_ca_depth if policy is not None else None,
|
|
102
|
+
'max_cert_ttl_seconds': policy.max_cert_ttl_seconds if policy is not None else None,
|
|
103
|
+
'next_step': 'explicit_operator_review_only'})
|
|
104
|
+
result['ca_inventory'] = inventory
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def preflight(config_dir):
|
|
109
|
+
"""The config directory is explicit; no accidental default production probe."""
|
|
110
|
+
result = {'format': 'msg-preflight-v1', 'captured_at': datetime.now(UTC).isoformat(),
|
|
111
|
+
'decision': 'blocked', 'source_kind': 'unattested', 'mutation_performed': False,
|
|
112
|
+
'root_private_material': 'not_opened', 'outbound_effects': 'not_run',
|
|
113
|
+
'field_evidence': [{'id': key, 'issue': issue, 'required': description, 'status': 'missing'}
|
|
114
|
+
for key, issue, description in _FIELD_EVIDENCE]}
|
|
115
|
+
try:
|
|
116
|
+
settings = load_settings(Path(config_dir))
|
|
117
|
+
config = settings.config_dir / 'msgd.toml'
|
|
118
|
+
if not config.is_file():
|
|
119
|
+
config = settings.config_dir / 'server.toml'
|
|
120
|
+
result['config_digest'] = digest(config.read_bytes())
|
|
121
|
+
except (OSError, Failure, ValueError, KeyError):
|
|
122
|
+
return dict(result, error='preflight_configuration_unavailable')
|
|
123
|
+
try:
|
|
124
|
+
with psycopg.connect(settings.server.postgres_dsn, connect_timeout=5) as connection:
|
|
125
|
+
connection.execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY')
|
|
126
|
+
result['database'] = inspect_database(connection)
|
|
127
|
+
except (psycopg.Error, Failure, ValueError, KeyError):
|
|
128
|
+
result['error'] = 'preflight_database_unavailable_or_inconsistent'
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main():
|
|
133
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
134
|
+
parser.add_argument('--config', type=Path, required=True)
|
|
135
|
+
args = parser.parse_args()
|
|
136
|
+
report = preflight(args.config)
|
|
137
|
+
print(canonical(report).decode())
|
|
138
|
+
raise SystemExit(2 if report['decision'] == 'blocked' else 0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == '__main__':
|
|
142
|
+
main()
|