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/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()