msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/plugins/schemas.py ADDED
@@ -0,0 +1,5 @@
1
+ """Compatibility exports; shared wire schemas are owned by msg.core.schemas."""
2
+ from msg.core.schemas import (
3
+ STRING, IDENTIFIER, INTEGER, BOOLEAN, BYTES, REF, SCOPE, NETWORK_CONSTRAINTS,
4
+ GRANT, GRANTS, ISSUANCE, SIGNATURE, OUTPUT, obj,
5
+ )
msg/plugins/sharing.py ADDED
@@ -0,0 +1,300 @@
1
+ """Revocable, direct-subject read grants for one resource at a time."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import timedelta
5
+ import hashlib
6
+ import hmac
7
+
8
+ from msg.constants import ROOT_SUBJECT
9
+ from msg.core.codec import b64, canonical, loads, parse_time, unb64, wire
10
+ from msg.core.errors import Failure, require
11
+ from msg.core.models import HandlerOutput, ResourceRef
12
+ from msg.plugins.common import check_access, new_id, registration, resolve
13
+ from msg.security.sharing_policy import share_target_error
14
+ from msg.plugins.schemas import IDENTIFIER, STRING, obj
15
+
16
+
17
+ MAX_GRANT_TTL = timedelta(days=30)
18
+ MAX_LINK_BYTES = 65536
19
+
20
+
21
+ def _link_public(row):
22
+ return dict(zip(('id', 'resource_id', 'grantor', 'created_at', 'expires_at',
23
+ 'revoked_at'), row))
24
+
25
+
26
+ def _links_enabled(tx):
27
+ require(tx.setting('share_links_enabled', False) is True, 'share_links_disabled')
28
+
29
+
30
+ def _verifier(token):
31
+ try:
32
+ raw = unb64(token, limit=64)
33
+ except (Failure, ValueError):
34
+ raise Failure('share_link_unavailable') from None
35
+ require(32 <= len(raw) <= 64, 'share_link_unavailable')
36
+ return 'sha256:'+hashlib.sha256(b'share-link-v1\0'+raw).hexdigest()
37
+
38
+
39
+ async def _owned_resource(app, ctx, request, tx, value, *, mutation, owner=True):
40
+ rid = await resolve(tx, value)
41
+ resource = await tx.resource(rid)
42
+ require(ctx.principal.subject is not None and ctx.principal.actor == ctx.principal.subject,
43
+ 'share_subject_required')
44
+ require((not owner or resource.owner == ctx.principal.subject) and resource.state == 'active',
45
+ 'permission_denied')
46
+ require(not app.registry.resource_type(resource.type, resource.type_version).container,
47
+ 'share_container_forbidden')
48
+ if owner:
49
+ await check_access(app, ctx, request, tx, rid, 'manage' if mutation else 'read')
50
+ else:
51
+ await app.authorizer.require_base(ctx.principal,
52
+ f'{request.operation}@{request.contract_version}',rid,tx)
53
+ chain = (*await tx.ancestors(rid), resource)
54
+ error = share_target_error(app.registry, resource, chain, tx)
55
+ require(error is None, error or 'share_forbidden_resource')
56
+ return resource
57
+
58
+
59
+ async def _revocable_resource(app, ctx, request, tx, value, *, owner=True):
60
+ """Return (resource, live); a dormant target must not block narrowing access.
61
+
62
+ Archived/moved targets make a grant dormant, not revoked: restoring the
63
+ target would revive it, so revocation stays available under the ceiling.
64
+ """
65
+ rid = await resolve(tx, value)
66
+ resource = await tx.resource(rid)
67
+ chain = (*await tx.ancestors(rid), resource)
68
+ if share_target_error(app.registry, resource, chain, tx) is None:
69
+ return await _owned_resource(app, ctx, request, tx, rid, mutation=True,
70
+ owner=owner), True
71
+ require(ctx.principal.subject is not None and ctx.principal.actor == ctx.principal.subject,
72
+ 'share_subject_required')
73
+ require(not owner or resource.owner == ctx.principal.subject, 'permission_denied')
74
+ await app.authorizer.require_base(ctx.principal,
75
+ f'{request.operation}@{request.contract_version}', rid, tx)
76
+ return resource, False
77
+
78
+
79
+ def _public(row):
80
+ return dict(zip(('id', 'resource_id', 'grantor', 'grantee', 'created_at', 'expires_at',
81
+ 'revoked_at'), row))
82
+
83
+
84
+ def _public_v2(row, now):
85
+ result=dict(zip(('id','resource_id','grantor','grantee','grantee_kind','parent_id',
86
+ 'operations','constraints','allow_reshare','created_at','expires_at',
87
+ 'revoked_at'),row))
88
+ result['operations']=loads(result['operations'])
89
+ result['constraints']=loads(result['constraints'])
90
+ result['allow_reshare']=bool(result['allow_reshare'])
91
+ result['status']='revoked' if result['revoked_at'] else (
92
+ 'expired' if parse_time(result['expires_at'])<=now else 'active')
93
+ return result
94
+
95
+
96
+ _V2_COLUMNS='''id,resource_id,grantor,grantee,grantee_kind,parent_id,operations,
97
+ constraints,allow_reshare,created_at,expires_at,revoked_at'''
98
+
99
+
100
+ def install(app):
101
+ op, finish = registration(app, 'sharing', ('identity',))
102
+
103
+ @op('sharing.grant', obj({'resource': IDENTIFIER, 'grantee': IDENTIFIER,
104
+ 'expires_at': STRING}, ('resource', 'grantee', 'expires_at')))
105
+ async def grant(ctx, request, tx):
106
+ a = request.arguments
107
+ resource = await _owned_resource(app, ctx, request, tx, a['resource'], mutation=True)
108
+ grantee = await resolve(tx, a['grantee'])
109
+ await tx.subject(grantee)
110
+ require(grantee not in {resource.owner, ROOT_SUBJECT}, 'invalid_share_grantee')
111
+ expires = parse_time(a['expires_at'])
112
+ require(ctx.now < expires <= ctx.now + MAX_GRANT_TTL, 'invalid_share_expiry')
113
+ tx.execute('''UPDATE share_grants SET revoked_at=?
114
+ WHERE resource_id=? AND grantee=? AND revoked_at IS NULL AND expires_at<=?''',
115
+ (wire(ctx.now), resource.id, grantee, wire(ctx.now)), write=True)
116
+ require(tx.one('''SELECT 1 FROM share_grants
117
+ WHERE resource_id=? AND grantee=? AND revoked_at IS NULL''',
118
+ (resource.id, grantee)) is None, 'share_already_active')
119
+ gid = new_id('share')
120
+ tx.execute('''INSERT INTO share_grants
121
+ (id,resource_id,grantor,grantee,created_at,expires_at,revoked_at)
122
+ VALUES (?,?,?,?,?,?,NULL)''',
123
+ (gid, resource.id, resource.owner, grantee, wire(ctx.now), wire(expires)), write=True)
124
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
125
+ return HandlerOutput(data={'grant': _public((gid, resource.id, resource.owner,
126
+ grantee, wire(ctx.now), wire(expires), None))})
127
+
128
+ @op('sharing.revoke', obj({'grant_id': IDENTIFIER}, ('grant_id',)))
129
+ async def revoke(ctx, request, tx):
130
+ gid = request.arguments['grant_id']
131
+ row = tx.one('''SELECT id,resource_id,grantor,grantee,created_at,expires_at,revoked_at
132
+ FROM share_grants WHERE id=?''', (gid,))
133
+ require(row is not None, 'share_not_found')
134
+ resource, _ = await _revocable_resource(app, ctx, request, tx, row[1])
135
+ require(row[2] == resource.owner, 'permission_denied')
136
+ if row[6] is None:
137
+ tx.execute('UPDATE share_grants SET revoked_at=? WHERE id=?',
138
+ (wire(ctx.now), gid), write=True)
139
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
140
+ row = (*row[:6], wire(ctx.now))
141
+ return HandlerOutput(data={'grant': _public(row)})
142
+
143
+ @op('sharing.list', obj({'resource': IDENTIFIER}, ('resource',)), effect='read')
144
+ async def list_grants(ctx, request, tx):
145
+ resource = await _owned_resource(app, ctx, request, tx,
146
+ request.arguments['resource'], mutation=False)
147
+ rows = tx.rows('''SELECT id,resource_id,grantor,grantee,created_at,expires_at,revoked_at
148
+ FROM share_grants WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
149
+ (resource.id, resource.owner))
150
+ return HandlerOutput(data={'grants': [_public(row) for row in rows]})
151
+
152
+ @op('sharing.grant',obj({'resource':IDENTIFIER,'grantee':IDENTIFIER,
153
+ 'grantee_kind':{'enum':['user','group']},'operations':{'type':'array',
154
+ 'items':{'enum':['read']},'minItems':1,'uniqueItems':True},
155
+ 'expires_at':STRING,'allow_reshare':{'type':'boolean'},
156
+ 'constraints':obj(), 'source_grant_id':IDENTIFIER},
157
+ ('resource','grantee','grantee_kind','operations','expires_at')),version=2)
158
+ async def grant_v2(ctx,request,tx):
159
+ a=request.arguments
160
+ resource=await _owned_resource(app,ctx,request,tx,a['resource'],mutation=True,
161
+ owner=False)
162
+ require(tuple(a['operations'])==('read',) and dict(a.get('constraints',{}))=={},
163
+ 'invalid_share_operations')
164
+ grantee=await resolve(tx,a['grantee'])
165
+ if a['grantee_kind']=='user':
166
+ await tx.subject(grantee)
167
+ else:
168
+ target=await tx.resource(grantee)
169
+ require(target.type=='organization' and target.state=='active',
170
+ 'invalid_share_grantee')
171
+ require(grantee not in {resource.owner,ROOT_SUBJECT},'invalid_share_grantee')
172
+ expires=parse_time(a['expires_at'])
173
+ require(ctx.now<expires<=ctx.now+MAX_GRANT_TTL,'invalid_share_expiry')
174
+ parent=a.get('source_grant_id')
175
+ if ctx.principal.subject==resource.owner:
176
+ require(parent is None,'share_source_not_needed')
177
+ await check_access(app,ctx,request,tx,resource.id,'manage')
178
+ else:
179
+ require(parent is not None,'share_source_required')
180
+ source=tx.one('''SELECT resource_id,expires_at,operations,constraints
181
+ FROM share_grants_v2 WHERE id=?''',(parent,))
182
+ require(source is not None and source[0]==resource.id and
183
+ await app.authorizer.share_source_active(resource,parent,
184
+ ctx.principal.subject,ctx.now,tx,reshare=True),'share_source_unavailable')
185
+ require(expires<=parse_time(source[1]) and
186
+ set(a['operations'])<=set(loads(source[2])) and
187
+ a.get('constraints',{})==loads(source[3]),'share_scope_exceeded')
188
+ gid=new_id('share')
189
+ tx.execute('''INSERT INTO share_grants_v2
190
+ (id,resource_id,grantor,grantee,grantee_kind,parent_id,operations,constraints,
191
+ allow_reshare,created_at,expires_at,revoked_at)
192
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,NULL)''',
193
+ (gid,resource.id,ctx.principal.subject,grantee,a['grantee_kind'],parent,
194
+ canonical(a['operations']).decode(),canonical(a.get('constraints',{})).decode(),
195
+ int(a.get('allow_reshare',False)),wire(ctx.now),wire(expires)),write=True)
196
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
197
+ row=tx.one(f'SELECT {_V2_COLUMNS} FROM share_grants_v2 WHERE id=?',(gid,))
198
+ return HandlerOutput(data={'grant':_public_v2(row,ctx.now)})
199
+
200
+ @op('sharing.revoke',obj({'grant_id':IDENTIFIER},('grant_id',)),version=2)
201
+ async def revoke_v2(ctx,request,tx):
202
+ gid=request.arguments['grant_id']
203
+ row=tx.one(f'SELECT {_V2_COLUMNS} FROM share_grants_v2 WHERE id=?',(gid,))
204
+ require(row is not None,'share_not_found')
205
+ resource,live=await _revocable_resource(app,ctx,request,tx,row[1],owner=False)
206
+ require(ctx.principal.subject in {resource.owner,row[2]},'permission_denied')
207
+ if live and ctx.principal.subject==resource.owner:
208
+ await check_access(app,ctx,request,tx,resource.id,'manage')
209
+ if row[11] is None:
210
+ tx.execute('UPDATE share_grants_v2 SET revoked_at=? WHERE id=?',
211
+ (wire(ctx.now),gid),write=True)
212
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
213
+ row=(*row[:11],wire(ctx.now))
214
+ return HandlerOutput(data={'grant':_public_v2(row,ctx.now)})
215
+
216
+ @op('sharing.list',obj({'resource':IDENTIFIER},('resource',)),effect='read',version=2)
217
+ async def list_v2(ctx,request,tx):
218
+ resource=await _owned_resource(app,ctx,request,tx,
219
+ request.arguments['resource'],mutation=False,owner=False)
220
+ rows=tx.rows(f'''SELECT {_V2_COLUMNS} FROM share_grants_v2
221
+ WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
222
+ (resource.id,ctx.principal.subject))
223
+ return HandlerOutput(data={'grants':[_public_v2(row,ctx.now) for row in rows]})
224
+
225
+ @op('sharing.link_create', obj({'resource': IDENTIFIER,
226
+ 'verifier': {'type':'string','pattern':'^sha256:[0-9a-f]{64}$'},
227
+ 'expires_at': STRING}, ('resource','verifier','expires_at')), signature=True)
228
+ async def link_create(ctx, request, tx):
229
+ _links_enabled(tx)
230
+ require(ctx.principal.method=='signature' and
231
+ ctx.principal.actor==ctx.principal.subject, 'share_subject_required')
232
+ a=request.arguments
233
+ resource=await _owned_resource(app,ctx,request,tx,a['resource'],mutation=True)
234
+ expires=parse_time(a['expires_at'])
235
+ require(ctx.now<expires<=ctx.now+MAX_GRANT_TTL,'invalid_share_expiry')
236
+ require(resource.revision is not None,'share_link_content_required')
237
+ revision=await tx.revision(ResourceRef(id=resource.id))
238
+ require(revision.content.size<=MAX_LINK_BYTES,'share_link_content_too_large')
239
+ require(tx.one('SELECT 1 FROM share_links WHERE verifier=?',(a['verifier'],)) is None,
240
+ 'share_link_verifier_in_use')
241
+ lid=new_id('link')
242
+ tx.execute('''INSERT INTO share_links
243
+ (id,resource_id,grantor,credential_id,verifier,created_at,expires_at,revoked_at)
244
+ VALUES (?,?,?,?,?,?,?,NULL)''',
245
+ (lid,resource.id,resource.owner,ctx.principal.credential_id,a['verifier'],
246
+ wire(ctx.now),wire(expires)),write=True)
247
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
248
+ return HandlerOutput(data={'link':_link_public((lid,resource.id,resource.owner,
249
+ wire(ctx.now),wire(expires),None)),
250
+ 'endpoint':'/-/p/sharing.link_read',
251
+ 'proof':'client-held token in JSON request body'})
252
+
253
+ @op('sharing.link_revoke',obj({'link_id':IDENTIFIER},('link_id',)),signature=True)
254
+ async def link_revoke(ctx,request,tx):
255
+ lid=request.arguments['link_id']
256
+ row=tx.one('''SELECT id,resource_id,grantor,created_at,expires_at,revoked_at
257
+ FROM share_links WHERE id=?''',(lid,))
258
+ require(row is not None,'share_link_unavailable')
259
+ resource=await _owned_resource(app,ctx,request,tx,row[1],mutation=True)
260
+ require(ctx.principal.method=='signature' and row[2]==resource.owner,
261
+ 'permission_denied')
262
+ if row[5] is None:
263
+ tx.execute('UPDATE share_links SET revoked_at=? WHERE id=?',
264
+ (wire(ctx.now),lid),write=True)
265
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
266
+ row=(*row[:5],wire(ctx.now))
267
+ return HandlerOutput(data={'link':_link_public(row)})
268
+
269
+ @op('sharing.link_list',obj({'resource':IDENTIFIER},('resource',)),effect='read')
270
+ async def link_list(ctx,request,tx):
271
+ resource=await _owned_resource(app,ctx,request,tx,
272
+ request.arguments['resource'],mutation=False)
273
+ rows=tx.rows('''SELECT id,resource_id,grantor,created_at,expires_at,revoked_at
274
+ FROM share_links WHERE resource_id=? AND grantor=? ORDER BY created_at,id''',
275
+ (resource.id,resource.owner))
276
+ return HandlerOutput(data={'links':[_link_public(row) for row in rows]})
277
+
278
+ @op('sharing.link_read',obj({'link_id':IDENTIFIER,'token':STRING},
279
+ ('link_id','token')),effect='read')
280
+ async def link_read(ctx,request,tx):
281
+ # Never accept signed identity as an alternative to the bearer source.
282
+ require(ctx.principal.method=='anonymous','share_link_unavailable')
283
+ _links_enabled(tx)
284
+ a=request.arguments
285
+ verifier=_verifier(a['token'])
286
+ row=tx.one('''SELECT resource_id,grantor,credential_id,verifier,expires_at,revoked_at
287
+ FROM share_links WHERE id=?''',(a['link_id'],))
288
+ require(row is not None and hmac.compare_digest(row[3],verifier) and
289
+ row[5] is None and parse_time(row[4])>ctx.now,'share_link_unavailable')
290
+ resource=await tx.resource(row[0])
291
+ chain=(*await tx.ancestors(resource.id),resource)
292
+ require(await app.authorizer.share_link_read_allowed(row[1],row[2],resource,
293
+ chain,ctx.now,tx),'share_link_unavailable')
294
+ revision=await tx.revision(ResourceRef(id=resource.id))
295
+ require(revision.content.size<=MAX_LINK_BYTES,'share_link_unavailable')
296
+ body=await app.contents.read_bytes(revision.content,limit=MAX_LINK_BYTES)
297
+ return HandlerOutput(data={'id':resource.id,'revision':revision.id,
298
+ 'media_type':revision.content.media_type,'data':b64(body)})
299
+
300
+ finish()
msg/plugins/store.py ADDED
@@ -0,0 +1,249 @@
1
+ """A catalog and immutable deposits; no purchase or escrow operations live here."""
2
+ from __future__ import annotations
3
+
4
+ from msg.core.codec import canonical, digest, loads, parse_time, wire
5
+ from msg.core.errors import Failure, require
6
+ from msg.core.models import HandlerOutput, ResourceRef, ResourceTypeSpec
7
+ from msg.plugins.common import (assert_generation, check_access, create_resource,
8
+ new_id, operation_id, registration, resolve,
9
+ revise_resource)
10
+ from msg.plugins.schemas import IDENTIFIER, INTEGER, REF, STRING, obj
11
+
12
+
13
+ KINDS = ('file', 'text', 'secret', 'bundle', 'entitlement', 'service')
14
+ MODES = ('managed_instant', 'sealed_manual', 'service')
15
+ STATES = ('draft', 'active', 'paused', 'closed')
16
+ MAX_REFS = 32
17
+ MAX_MANIFEST_BYTES = 65536
18
+
19
+
20
+ def _subject(ctx):
21
+ subject = ctx.principal.subject
22
+ require(subject is not None and ctx.principal.actor == subject and
23
+ ctx.principal.method == 'signature', 'signature_required')
24
+ return subject
25
+
26
+
27
+ async def _listing(app, ctx, request, tx, value, *, seller=False):
28
+ try:
29
+ rid = await resolve(tx, value)
30
+ resource = await tx.resource(rid)
31
+ require(resource.type == 'listing' and resource.parent == 't_store', 'listing_not_found')
32
+ await check_access(app, ctx, request, tx, rid, 'read')
33
+ if seller:
34
+ require(resource.owner == _subject(ctx), 'listing_not_found')
35
+ return resource
36
+ except Failure as exc:
37
+ if exc.code in {'not_found', 'permission_denied', 'credential_ceiling',
38
+ 'certificate_gate', 'ancestor_inactive'}:
39
+ raise Failure('listing_not_found') from None
40
+ raise
41
+
42
+
43
+ async def _body(app, tx, resource, revision=None):
44
+ rev = await tx.revision(ResourceRef(id=resource.id, revision=revision))
45
+ body = loads(await app.contents.read_bytes(rev.content))
46
+ # Listings deposited before the sale/bounty split were all sale listings.
47
+ return {'mode': 'sale', **body}, rev
48
+
49
+
50
+ def _public(resource, body):
51
+ # A package identifier is not a capability; package payloads are seller-only.
52
+ return {'listing_id': resource.id, 'listing_revision': resource.revision,
53
+ 'seller': resource.owner, **body,
54
+ 'terms_revision': resource.revision,
55
+ 'terms_digest': digest(body['terms'])}
56
+
57
+
58
+ def _validate(body, now):
59
+ require(body['mode'] == 'sale', 'listing_mode_unsupported')
60
+ require(body['currency_id'] == 'primary', 'unsupported_currency')
61
+ require(type(body['price_minor']) is int and 0 < body['price_minor'] <= 10**18,
62
+ 'invalid_price')
63
+ require(type(body['quantity']) is int and 0 < body['quantity'] <= 10**9,
64
+ 'invalid_quantity')
65
+ require(len(canonical(body)) <= MAX_MANIFEST_BYTES, 'listing_too_large')
66
+ if body['expires_at'] is not None and body['state'] != 'closed':
67
+ require(parse_time(body['expires_at']) > now, 'listing_expired')
68
+ if body['delivery_mode'] == 'service':
69
+ require(body['item_kind'] == 'service' and body['package_ref'] is None,
70
+ 'invalid_delivery_mode')
71
+ else:
72
+ require(body['item_kind'] != 'service', 'invalid_delivery_mode')
73
+ if body['state'] == 'active' and body['delivery_mode'] == 'managed_instant':
74
+ require(body['package_ref'] is not None, 'package_required')
75
+
76
+
77
+ async def _package_row(tx, package_id):
78
+ return tx.one('''SELECT id,listing_id,listing_revision,seller,revision,kind,manifest,
79
+ payload_refs,digest,total_size,delivery_mode,deposited_at FROM store_packages WHERE id=?''',
80
+ (package_id,))
81
+
82
+
83
+ def _package_public(row):
84
+ return dict(zip(('id', 'listing_id', 'listing_revision', 'seller', 'revision',
85
+ 'kind', 'manifest', 'payload_refs', 'digest', 'total_size',
86
+ 'delivery_mode', 'deposited_at'),
87
+ (*row[:6], loads(row[6]), loads(row[7]), *row[8:])))
88
+
89
+
90
+ def install(app):
91
+ op, finish = registration(app, 'store', ('content',))
92
+ listing_fields = {
93
+ 'name': {'type':'string','minLength':1,'maxLength':120},
94
+ 'mode': {'enum': ['sale', 'bounty']},
95
+ 'item_kind': {'enum': list(KINDS)},
96
+ 'price_minor': INTEGER,
97
+ 'currency_id': IDENTIFIER,
98
+ 'quantity': INTEGER,
99
+ 'delivery_mode': {'enum': list(MODES)},
100
+ 'package_ref': {'type':['string','null']},
101
+ 'escrow_policy': {'type':'string','minLength':1,'maxLength':80},
102
+ 'dispute_policy': {'type':'string','minLength':1,'maxLength':80},
103
+ 'terms': {'type':'string','minLength':1,'maxLength':16384},
104
+ 'expires_at': {'type':['string','null']},
105
+ 'state': {'enum': list(STATES)},
106
+ }
107
+ creation_required = ('name', 'item_kind', 'price_minor', 'currency_id', 'quantity',
108
+ 'delivery_mode', 'escrow_policy', 'dispute_policy', 'terms')
109
+
110
+ create_fields = {key:value for key,value in listing_fields.items()
111
+ if key not in {'state','package_ref'}}
112
+ @op('store.listing_create', obj(create_fields, creation_required), signature=True)
113
+ async def listing_create(ctx, request, tx):
114
+ seller = _subject(ctx)
115
+ await app.authorizer.require_base(ctx.principal, operation_id(request), seller, tx)
116
+ await check_access(app, ctx, request, tx, 't_store', 'create')
117
+ args = request.arguments
118
+ body = {key: args.get(key) for key in create_fields if key != 'name'}
119
+ body['mode'] = args.get('mode', 'sale')
120
+ body['package_ref'] = None
121
+ body['expires_at'] = args.get('expires_at')
122
+ body['state'] = 'draft'
123
+ _validate(body, ctx.now)
124
+ resource = await create_resource(app, ctx, request, tx, parent='t_store',
125
+ type='listing', name=args['name'], body=canonical(body),
126
+ media_type='application/json', mode=0o644)
127
+ require(resource.owner == seller, 'seller_mismatch')
128
+ return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=resource.revision),),
129
+ data={'listing': _public(resource, body),
130
+ 'generation': resource.generation})
131
+
132
+ @op('store.listing_update', obj({'id':IDENTIFIER, 'price_minor':INTEGER,
133
+ 'quantity':INTEGER, 'terms':listing_fields['terms'], 'state':listing_fields['state'],
134
+ 'expires_at':listing_fields['expires_at'], 'package_ref':listing_fields['package_ref']},
135
+ ('id',)), signature=True)
136
+ async def listing_update(ctx, request, tx):
137
+ resource = await _listing(app, ctx, request, tx, request.arguments['id'], seller=True)
138
+ await check_access(app, ctx, request, tx, resource.id, 'write')
139
+ await assert_generation(request, resource)
140
+ old, _ = await _body(app, tx, resource)
141
+ require(old['mode'] == 'sale', 'listing_mode_unsupported')
142
+ updates = {key:value for key,value in request.arguments.items() if key != 'id'}
143
+ require(bool(updates), 'empty_listing_update')
144
+ body = {**old, **updates}
145
+ require(old['state'] != 'closed' or body['state'] == 'closed', 'listing_closed')
146
+ if body['package_ref'] is not None:
147
+ row = await _package_row(tx, body['package_ref'])
148
+ require(row is not None and row[1] == resource.id and row[3] == resource.owner and
149
+ row[5] == body['item_kind'] and row[10] == body['delivery_mode'],
150
+ 'package_not_found')
151
+ _validate(body, ctx.now)
152
+ updated = await revise_resource(app, ctx, request, tx, resource, canonical(body),
153
+ 'application/json')
154
+ return HandlerOutput(resources=(ResourceRef(id=updated.id, revision=updated.revision),),
155
+ data={'listing': _public(updated, body),
156
+ 'generation':updated.generation})
157
+
158
+ @op('store.listing_get', obj({'id':IDENTIFIER, 'revision':IDENTIFIER}, ('id',)),
159
+ effect='read')
160
+ async def listing_get(ctx, request, tx):
161
+ resource = await _listing(app, ctx, request, tx, request.arguments['id'])
162
+ revision = request.arguments.get('revision')
163
+ body, rev = await _body(app, tx, resource, revision)
164
+ result = _public(resource, body)
165
+ if body['mode'] == 'bounty':
166
+ from msg.plugins.bounty import projection
167
+ live = projection(tx,resource.id,ctx.now)
168
+ result['current_state'] = live['state']
169
+ result['pause_reason'] = live['pause_reason']
170
+ result['current_budget_minor'] = live['budget_minor']
171
+ result['escrow_balance_minor'] = live['escrow_balance_minor']
172
+ result['paid_claims'] = live['paid_claims']
173
+ if revision is None:
174
+ result['state'] = live['state']
175
+ result['budget_minor'] = live['budget_minor']
176
+ result['listing_revision'] = rev.id
177
+ result['terms_revision'] = rev.id
178
+ return HandlerOutput(resources=(ResourceRef(id=resource.id, revision=rev.id),),
179
+ data={'listing':result})
180
+
181
+ @op('store.package_deposit', obj({'listing_id':IDENTIFIER,
182
+ 'listing_revision':IDENTIFIER,
183
+ 'manifest':{'type':'object'},
184
+ 'payload_refs':{'type':'array','items':REF,'minItems':1,'maxItems':MAX_REFS}},
185
+ ('listing_id','listing_revision','manifest','payload_refs')), signature=True)
186
+ async def package_deposit(ctx, request, tx):
187
+ from msg.plugins.communication import direct_ancestor
188
+ resource = await _listing(app, ctx, request, tx, request.arguments['listing_id'], seller=True)
189
+ body, _ = await _body(app, tx, resource)
190
+ require(body['mode'] == 'sale', 'listing_mode_unsupported')
191
+ require(resource.revision == request.arguments['listing_revision'],
192
+ 'listing_revision_conflict')
193
+ require(body['delivery_mode'] == 'managed_instant' and
194
+ body['item_kind'] in {'file','bundle','text'} and body['state'] != 'closed',
195
+ 'package_mode_unsupported')
196
+ manifest = request.arguments['manifest']
197
+ require(len(canonical(manifest)) <= MAX_MANIFEST_BYTES, 'manifest_too_large')
198
+ refs=[]
199
+ blobs=[]
200
+ total=0
201
+ for ref in request.arguments['payload_refs']:
202
+ require(ref['revision'] is not None, 'payload_revision_required')
203
+ rid=await resolve(tx,ref['id'])
204
+ item=await tx.resource(rid)
205
+ require(item.owner == resource.owner and item.state == 'active' and
206
+ item.type in {'file','attachment'}, 'payload_not_owned')
207
+ require(await direct_ancestor(tx, rid) is None, 'payload_private_conversation')
208
+ await check_access(app, ctx, request, tx, rid, 'read')
209
+ revision=await tx.revision(ResourceRef(id=rid,revision=ref['revision']))
210
+ if body['item_kind'] == 'text':
211
+ require(revision.content.media_type.startswith('text/'), 'text_payload_required')
212
+ blobs.append(revision.content)
213
+ total+=revision.content.size
214
+ require(total <= 1024**4, 'package_too_large')
215
+ refs.append({'id':rid,'revision':revision.id,'blob':wire(revision.content)})
216
+ require(len({(r['id'],r['revision']) for r in refs}) == len(refs),
217
+ 'duplicate_payload_ref')
218
+ require(body['item_kind'] == 'bundle' or len(refs) == 1,
219
+ 'file_package_single_payload')
220
+ package_id=new_id('pkg')
221
+ package_revision=new_id('pv')
222
+ snapshot={'seller':resource.owner,'listing_id':resource.id,
223
+ 'listing_revision':resource.revision,'revision':package_revision,
224
+ 'kind':body['item_kind'],'manifest':manifest,'payload_refs':refs,
225
+ 'total_size':total,'delivery_mode':'managed_instant'}
226
+ package_digest=digest(snapshot)
227
+ for blob in blobs:
228
+ tx.on_rollback(lambda blob=blob: app.contents.unpin(blob, package_revision))
229
+ await app.contents.pin(blob, package_revision)
230
+ tx.execute('''INSERT INTO store_packages
231
+ (id,listing_id,listing_revision,seller,revision,kind,manifest,payload_refs,
232
+ digest,total_size,delivery_mode,deposited_at)
233
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
234
+ (package_id,resource.id,resource.revision,resource.owner,package_revision,
235
+ body['item_kind'],canonical(manifest).decode(),canonical(refs).decode(),
236
+ package_digest,total,'managed_instant',wire(ctx.now)),write=True)
237
+ return HandlerOutput(resources=(ResourceRef(id=resource.id,revision=resource.revision),),
238
+ data={'package':{'id':package_id,'revision':package_revision,
239
+ 'digest':package_digest,'total_size':total}})
240
+
241
+ @op('store.package_get', obj({'id':IDENTIFIER}, ('id',)), effect='read')
242
+ async def package_get(ctx, request, tx):
243
+ row=await _package_row(tx,request.arguments['id'])
244
+ require(row is not None and row[3] == _subject(ctx), 'package_not_found')
245
+ await _listing(app, ctx, request, tx, row[1], seller=True)
246
+ return HandlerOutput(data={'package':_package_public(row)})
247
+
248
+ finish((ResourceTypeSpec(name='listing',version=1,container=False,
249
+ content_schema=None,operations=frozenset(),relations=frozenset()),))
msg/plugins/system.py ADDED
@@ -0,0 +1,75 @@
1
+ """Finite online administration: no paths, root keys, plugin code or shell commands."""
2
+ from __future__ import annotations
3
+ from msg.constants import ROOT_SPACE
4
+ from msg.core.codec import canonical, digest, wire
5
+ from msg.core.errors import require
6
+ from msg.core.models import AuditEvent, Event, EffectJob, HandlerOutput, ResourceRef
7
+ from msg.plugins.common import new_id, registration, operation_id
8
+ from msg.plugins.communication import event_id
9
+ from msg.plugins.schemas import obj, BOOLEAN, STRING
10
+
11
+
12
+ MAINTENANCE_ACTIONS_V1 = ('cleanup_expired', 'rebuild_search', 'collect_garbage')
13
+ MAINTENANCE_ACTIONS = (*MAINTENANCE_ACTIONS_V1, 'deliver_due_todos')
14
+ RUNTIME_SCHEMA = obj({'accept_writes':BOOLEAN, 'status_message':{'type':'string','maxLength':300},
15
+ 'cleanup_enabled':BOOLEAN})
16
+
17
+
18
+ def install(app):
19
+ op, finish = registration(app, 'system', ('identity','content'))
20
+
21
+ async def authorized(ctx, request, tx, capability):
22
+ require(await app.authorizer.has(ctx.principal, capability, operation_id(request), ROOT_SPACE, tx),
23
+ 'capability_required')
24
+
25
+ @op('system.inspect', obj(), effect='read', signature=True)
26
+ async def inspect(ctx, request, tx):
27
+ await authorized(ctx, request, tx, 'system.inspect')
28
+ # Deliberately omits environment, credentials, filesystem paths and keys.
29
+ return HandlerOutput(data={'schema_version':tx.one('SELECT version FROM schema_version')[0],
30
+ 'resources':tx.one('SELECT COUNT(*) FROM resources')[0],
31
+ 'pending_jobs':tx.one("SELECT COUNT(*) FROM jobs WHERE state='pending'")[0],
32
+ 'uncertain_jobs':tx.one("SELECT COUNT(*) FROM jobs WHERE state='uncertain'")[0],
33
+ 'runtime':tx.setting('runtime_config',{'accept_writes':True,'cleanup_enabled':True})})
34
+
35
+ @op('system.config', obj({'values':RUNTIME_SCHEMA},('values',)), signature=True)
36
+ async def configure(ctx, request, tx):
37
+ await authorized(ctx, request, tx, 'system.config')
38
+ before = tx.setting('runtime_config', {'accept_writes':True,'cleanup_enabled':True})
39
+ after = {**before, **request.arguments['values']}
40
+ tx.set_setting('runtime_config', after)
41
+ event = Event(id=new_id('audit'), type='system.config', time=ctx.now, request_id=request.request_id,
42
+ actor=ctx.principal.actor, subject=ctx.principal.subject, resources=(ResourceRef(id=ROOT_SPACE),), data={})
43
+ await tx.append_audit(AuditEvent(event=event, authority=tuple(ResourceRef(id=c) for c in ctx.principal.certificates),
44
+ before_digest=digest(before), after_digest=digest(after), previous_digest=None, entry_digest='', result='configured'))
45
+ return HandlerOutput(data={'runtime':after})
46
+
47
+ @op('system.share_links_set',obj({'enabled':BOOLEAN},('enabled',)),signature=True)
48
+ async def share_links_set(ctx,request,tx):
49
+ await authorized(ctx,request,tx,'system.config')
50
+ before=bool(tx.setting('share_links_enabled',False))
51
+ after=request.arguments['enabled']
52
+ tx.set_setting('share_links_enabled',after)
53
+ event=Event(id=new_id('audit'),type='system.share_links_set',time=ctx.now,
54
+ request_id=request.request_id,actor=ctx.principal.actor,subject=ctx.principal.subject,
55
+ resources=(ResourceRef(id=ROOT_SPACE),),data={'enabled':after})
56
+ await tx.append_audit(AuditEvent(event=event,
57
+ authority=tuple(ResourceRef(id=cid) for cid in ctx.principal.certificates),
58
+ before_digest=digest({'enabled':before}),after_digest=digest({'enabled':after}),
59
+ previous_digest=None,entry_digest='',result='configured'))
60
+ return HandlerOutput(data={'enabled':after})
61
+
62
+ @op('system.maintenance', obj({'action':{'enum':list(MAINTENANCE_ACTIONS_V1)}},('action',)),
63
+ effect='external', signature=True)
64
+ @op('system.maintenance', obj({'action':{'enum':list(MAINTENANCE_ACTIONS)}},('action',)),
65
+ effect='external', signature=True, version=2)
66
+ async def maintenance(ctx, request, tx):
67
+ await authorized(ctx, request, tx, 'system.maintenance')
68
+ eid = event_id(request, ctx.principal.subject)
69
+ job = EffectJob(id=new_id('job'), event_id=eid, kind='maintenance', dedupe_key='maintenance:'+eid,
70
+ principal=ctx.principal, operation=request.operation,
71
+ arguments={'action':request.arguments['action'],'request_id':request.request_id},
72
+ state='pending', attempts=0, next_attempt_at=ctx.now, lease_until=None)
73
+ await tx.enqueue(job)
74
+ return HandlerOutput(data={'job_id':job.id,'state':'pending'})
75
+ finish()