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
@@ -0,0 +1,162 @@
1
+ """Replies are posts; likes and ACK are explicit authenticated facts."""
2
+ from __future__ import annotations
3
+ from msg.core.codec import canonical,decode,wire,digest,loads,b64
4
+ from msg.core.errors import Failure,require
5
+ from msg.core.models import ResourceRef,Relation,HandlerOutput
6
+ from msg.core.requests import signing_bytes
7
+ from msg.plugins.common import *
8
+ from msg.plugins.schemas import *
9
+ from msg.plugins.content import create_post
10
+ from msg.plugins.discovery import visible,read_projection
11
+ from msg.security.policy import CERTGATE
12
+
13
+
14
+ async def gates(app,ctx,request,tx,rid):
15
+ await app.authorizer._ceiling(ctx.principal,operation_id(request),rid,tx)
16
+ chain=(*await tx.ancestors(rid),await tx.resource(rid))
17
+ if any(r.mode&CERTGATE for r in chain):
18
+ require(await app.authorizer.has(ctx.principal,'resource.certified_write',operation_id(request),rid,tx),'certificate_gate')
19
+
20
+
21
+ def install(app):
22
+ op,finish=registration(app,'discussion',('identity','content'))
23
+ fields={k:v for k,v in app.post_fields.items() if k!='parent'}
24
+
25
+ @op('discussion.reply',obj({**fields,'target':REF},('target',)))
26
+ async def reply(ctx,request,tx):
27
+ target=decode(ResourceRef,request.arguments['target'])
28
+ original=await tx.resource(target.id)
29
+ require(original.type=='post' and original.state=='active','not_a_post')
30
+ await check_access(app,ctx,request,tx,target.id,'read')
31
+ revision=await tx.revision(target)
32
+ parent=original.parent
33
+ require(tx.setting('policy:'+parent,{}).get('reply_open',True),'replies_closed')
34
+ await check_access(app,ctx,request,tx,parent,'create')
35
+ thread=next((r.target for r in revision.relations if r.type=='thread_root'),ResourceRef(id=original.id,revision=revision.id))
36
+ require((await tx.resource(thread.id)).parent==parent,'cross_thread_reply')
37
+ relations=(Relation(type='reply_to',target=ResourceRef(id=original.id,revision=revision.id)),Relation(type='thread_root',target=thread))
38
+ resource,meta=await create_post(app,ctx,request,tx,parent=parent,relations=relations)
39
+ return output_for(resource,**meta)
40
+
41
+ async def quote(ctx,request,tx):
42
+ from msg.plugins.communication import direct_ancestor
43
+ target=decode(ResourceRef,request.arguments['target'])
44
+ await check_access(app,ctx,request,tx,target.id,'read')
45
+ require((await tx.resource(target.id)).type!='legacy_directive',
46
+ 'legacy_directive_not_shareable')
47
+ require(await direct_ancestor(tx,target.id) is None,'dm_reference_private')
48
+ source=await tx.revision(target)
49
+ parent=await resolve(tx,request.arguments['parent'])
50
+ await check_access(app,ctx,request,tx,parent,'create')
51
+ kind='quote' if request.operation=='discussion.quote' else 'repost'
52
+ relation=Relation(type=kind,target=ResourceRef(id=target.id,revision=source.id))
53
+ resource,meta=await create_post(app,ctx,request,tx,parent=parent,relations=(relation,))
54
+ return output_for(resource,**meta)
55
+ for name in ('discussion.quote','discussion.repost'):
56
+ op(name,obj({**app.post_fields,'target':REF},('parent','target','body')))(quote)
57
+
58
+ async def reaction(ctx,request,tx):
59
+ rid=await resolve(tx,request.arguments['id'])
60
+ resource=await tx.resource(rid)
61
+ require(resource.type=='post' and resource.state=='active','not_a_post')
62
+ await check_access(app,ctx,request,tx,rid,'read')
63
+ await gates(app,ctx,request,tx,rid)
64
+ if request.operation=='discussion.like':
65
+ tx.execute("INSERT INTO reactions VALUES (?,?,?,? ,?) ON CONFLICT(subject,resource,kind,revision) DO NOTHING",
66
+ (ctx.principal.subject,rid,'like','',canonical({'actor':ctx.principal.actor,'time':wire(ctx.now)}).decode()),write=True)
67
+ else:
68
+ tx.execute("DELETE FROM reactions WHERE subject=? AND resource=? AND kind='like'",(ctx.principal.subject,rid),write=True)
69
+ count=tx.one("SELECT COUNT(*) FROM reactions WHERE resource=? AND kind='like'",(rid,))[0]
70
+ return HandlerOutput(resources=(ResourceRef(id=rid),),data={'likes':count,'liked':request.operation=='discussion.like'})
71
+ for name in ('discussion.like','discussion.unlike'):
72
+ op(name,obj({'id':IDENTIFIER},('id',)))(reaction)
73
+
74
+ @op('discussion.ack',obj({'target':REF,'digest':STRING},('target','digest')))
75
+ async def ack(ctx,request,tx):
76
+ target=decode(ResourceRef,request.arguments['target'])
77
+ require(target.revision is not None,'ack_revision_required')
78
+ await check_access(app,ctx,request,tx,target.id,'read')
79
+ await gates(app,ctx,request,tx,target.id)
80
+ rev=await tx.revision(target)
81
+ require(request.arguments['digest'] in {rev.content.digest,rev.manifest_digest},'ack_digest_mismatch')
82
+ require(ctx.principal.method in {'signature','token'},'ack_signature_or_token_required')
83
+ method=ctx.principal.method
84
+ record={'actor':ctx.principal.actor,'subject':ctx.principal.subject,'auth':method,'revision':rev.id,
85
+ 'digest':request.arguments['digest'],'time':wire(ctx.now)}
86
+ if method=='signature':
87
+ record.update(signature=wire(request.proof.signature),signed_envelope=b64(signing_bytes(request)))
88
+ kind='ack.'+method
89
+ if tx.one('SELECT 1 FROM reactions WHERE subject=? AND resource=? AND kind=? AND revision=?',
90
+ (ctx.principal.subject,target.id,kind,rev.id)) is None:
91
+ from msg.storage.capacity import require_reaction_capacity
92
+ require_reaction_capacity(tx)
93
+ tx.execute('INSERT INTO reactions VALUES (?,?,?,?,?) ON CONFLICT(subject,resource,kind,revision) DO NOTHING',
94
+ (ctx.principal.subject,target.id,kind,rev.id,canonical(record).decode()),write=True)
95
+ return HandlerOutput(resources=(target,),data={'auth':method,'acknowledged_revision':rev.id})
96
+
97
+ @op('discussion.acks',obj({'id':IDENTIFIER,'revision':IDENTIFIER,'cursor':STRING,
98
+ 'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read')
99
+ async def acks(ctx,request,tx):
100
+ from msg.plugins.discovery import next_link
101
+ rid=await resolve(tx,request.arguments['id'])
102
+ await check_access(app,ctx,request,tx,rid,'read')
103
+ filters="resource=? AND kind IN ('ack.signature','ack.token')"
104
+ params=[rid]
105
+ if request.arguments.get('revision'):
106
+ filters+=' AND revision=?'; params.append(request.arguments['revision'])
107
+ binding=digest({'id':rid,'revision':request.arguments.get('revision')})
108
+ position=app.cursors.decode(request.arguments['cursor'],'acks',binding) if request.arguments.get('cursor') else ['','','']
109
+ counts={kind:count for kind,count in tx.rows(f'SELECT kind,COUNT(DISTINCT subject) FROM reactions WHERE {filters} GROUP BY kind',params)}
110
+ limit=request.arguments.get('limit',50)
111
+ rows=tx.rows(f'SELECT body,subject,revision,kind FROM reactions WHERE {filters} AND (subject,revision,kind)>(?,?,?) ORDER BY subject,revision,kind LIMIT ?',
112
+ (*params,*position,limit+1))
113
+ data={'signed':counts.get('ack.signature',0),'token':counts.get('ack.token',0),
114
+ 'items':[loads(row[0]) for row in rows[:limit]]}
115
+ if len(rows)>limit:
116
+ cursor=app.cursors.encode('acks',binding,list(rows[limit-1][1:]))
117
+ data.update(cursor=cursor,next=next_link(app,request.operation,{**request.arguments,'cursor':cursor}),
118
+ next_requires_auth=ctx.principal.subject is not None)
119
+ return HandlerOutput(data=data)
120
+
121
+ @op('discussion.thread',obj({'id':IDENTIFIER,'cursor':STRING,
122
+ 'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read')
123
+ async def thread(ctx,request,tx):
124
+ from msg.plugins.discovery import next_link
125
+ rid=await resolve(tx,request.arguments['id'])
126
+ await check_access(app,ctx,request,tx,rid,'read')
127
+ revision=await tx.revision(ResourceRef(id=rid))
128
+ root=next((r.target.id for r in revision.relations if r.type=='thread_root'),rid)
129
+ binding=digest({'root':root,'focus':rid})
130
+ position=app.cursors.decode(request.arguments['cursor'],'thread',binding) if request.arguments.get('cursor') else [-1,'','']
131
+ items=[];limit=request.arguments.get('limit',50);more=False
132
+ sql="""SELECT r.id,r.created_at,CASE WHEN r.id=? THEN 0 ELSE 1 END AS priority
133
+ FROM resources r WHERE r.state='active' AND
134
+ (r.id=? OR EXISTS(SELECT 1 FROM relations rel WHERE rel.source_id=r.id
135
+ AND rel.revision_id=r.revision AND rel.type='thread_root' AND rel.target_id=?))
136
+ AND (CASE WHEN r.id=? THEN 0 ELSE 1 END,r.created_at,r.id)>(?,?,?)
137
+ ORDER BY priority,r.created_at,r.id"""
138
+ for id,created_at,priority in tx.execute(sql,(root,root,root,root,*position)):
139
+ if not await visible(app,ctx,request,tx,id):continue
140
+ if len(items)==limit:more=True;break
141
+ items.append(await read_projection(app,ctx,request,tx,id))
142
+ position=[priority,created_at,id]
143
+ data={'root':root,'items':items}
144
+ if more:
145
+ cursor=app.cursors.encode('thread',binding,position)
146
+ data.update(cursor=cursor,next=next_link(app,request.operation,{**request.arguments,'cursor':cursor}),
147
+ next_requires_auth=ctx.principal.subject is not None)
148
+ # Exact ancestors of a requested reply are references, not duplicated
149
+ # posts/authors/certificate chains. The root has its own normal page item.
150
+ if not request.arguments.get('cursor') and rid!=root:
151
+ ancestors=[];seen={rid};current=revision
152
+ for _ in range(128):
153
+ parent=next((r.target for r in current.relations if r.type=='reply_to'),None)
154
+ if parent is None or parent.id in seen:break
155
+ seen.add(parent.id)
156
+ if not await visible(app,ctx,request,tx,parent.id):break
157
+ ancestors.append(wire(parent))
158
+ if parent.id==root:break
159
+ current=await tx.revision(parent)
160
+ data['ancestors']=ancestors
161
+ return HandlerOutput(data=data)
162
+ finish()
@@ -0,0 +1,10 @@
1
+ """Packaged extensions register ordinary operations on the common executor."""
2
+ from msg.plugins.common import registration
3
+
4
+
5
+ def install(app):
6
+ op,finish=registration(app,'extensions',('identity','content','discovery','transfer'))
7
+ from msg.extensions import tools,keystore,hosting,repositories,ssh,rss
8
+ for extension in (tools,keystore,hosting,repositories,ssh,rss):
9
+ extension.register(app,op)
10
+ finish()
@@ -0,0 +1,78 @@
1
+ """A private, bounded read projection of existing watch rows, never a new feed."""
2
+ from datetime import timedelta
3
+
4
+ from msg.core.codec import wire
5
+ from msg.core.errors import require
6
+ from msg.core.models import HandlerOutput
7
+ from msg.core.read_query import ReadBudget
8
+ from msg.plugins.common import operation_id
9
+ from msg.plugins.discovery import short_subject_path, visible
10
+ from msg.plugins.schemas import obj
11
+
12
+
13
+ FOLLOWING_SCHEMA = obj({
14
+ 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
15
+ 'cursor': {'type': 'string', 'minLength': 1, 'maxLength': 8192},
16
+ })
17
+
18
+
19
+ def install(app, op):
20
+ @op('communication.following', FOLLOWING_SCHEMA, effect='read')
21
+ async def following(ctx, request, tx):
22
+ subject = ctx.principal.subject
23
+ require(subject is not None, 'authentication_required')
24
+ await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx)
25
+ budget = ReadBudget(ctx.deadline_monotonic, app.settings.server.limits.max_response_bytes)
26
+ principal = {'actor': ctx.principal.actor, 'subject': subject,
27
+ 'credential_id': ctx.principal.credential_id}
28
+ arguments = dict(request.arguments)
29
+ after, snapshot = '', ctx.now
30
+ if 'cursor' in arguments:
31
+ require(set(arguments) == {'cursor'}, 'cursor_query_mismatch')
32
+ token = arguments['cursor']
33
+ query, _ = app.cursors.inspect_page(token, ctx.now)
34
+ require(query.get('operation') == request.operation, 'cursor_query_mismatch')
35
+ arguments = query['arguments']
36
+ app.registry.validate(app.registry.operation(request.operation).input_schema, arguments)
37
+ require(set(arguments) == {'limit'}, 'cursor_query_mismatch')
38
+ after, snapshot = app.cursors.decode_page(token, request.operation, arguments,
39
+ principal, ctx.now)
40
+ else:
41
+ arguments = {'limit': arguments.get('limit', 20)}
42
+ limit = arguments['limit']
43
+ items = []
44
+ more = False
45
+ # Watches have no historical membership snapshot. The cursor bounds
46
+ # Resource creation and stable ID order; removals/revocation always win.
47
+ # Keyset scanning remains bounded even if most rows are now invisible.
48
+ while True:
49
+ budget.check()
50
+ rows = list(tx.execute(
51
+ 'SELECT r.id FROM watches AS w JOIN resources AS r ON r.id=w.resource '
52
+ "WHERE w.subject=? AND r.state='active' AND r.created_at<=? AND r.id>? "
53
+ 'ORDER BY r.id LIMIT 128', (subject, wire(snapshot), after)))
54
+ for (rid,) in rows:
55
+ budget.scan()
56
+ after = rid
57
+ if not await visible(app, ctx, request, tx, rid):
58
+ continue
59
+ if len(items) == limit:
60
+ more = True
61
+ break
62
+ resource = await tx.resource(rid)
63
+ budget.node(5)
64
+ items.append({'id': rid, 'type': resource.type, 'name': resource.name,
65
+ 'path': short_subject_path(await tx.path(rid)),
66
+ 'revision': resource.revision})
67
+ if more or len(rows) < 128:
68
+ break
69
+ # Never encode a hidden/scanned resource ID or a private count in the
70
+ # cursor. A cursor authenticates position, not access to any resource.
71
+ token = (app.cursors.encode_page(request.operation, arguments, items[-1]['id'],
72
+ snapshot, principal, ctx.now + timedelta(minutes=15))
73
+ if more else None)
74
+ data = {'items': items, 'pageInfo': {'hasNextPage': more, 'endCursor': token}}
75
+ if token:
76
+ data.update(cursor=token, next='/_r/c/' + token, next_requires_auth=True)
77
+ budget.output(data)
78
+ return HandlerOutput(data=data)
@@ -0,0 +1,52 @@
1
+ """Concrete byte-capacity entitlement consumer; never an authorization source.
2
+
3
+ The quota is the logical size of current website deployments owned by a subject.
4
+ Expired capacity does not delete published content; it only limits future writes.
5
+ """
6
+ from msg.core.codec import decode, loads, wire
7
+ from msg.core.errors import require
8
+ from msg.core.models import ResourceRef
9
+
10
+ ENTITLEMENT_KIND = 'hosting_bytes_v1'
11
+ PROVIDER_VERSION = 1
12
+ BASE_CAPACITY_BYTES = 10 * 1024 * 1024
13
+ MAX_CAPACITY_BYTES = 1024 ** 4
14
+
15
+
16
+ def extra_capacity(tx, subject, now):
17
+ return int(tx.one('''SELECT COALESCE(SUM(quantity),0) FROM resource_entitlements
18
+ WHERE subject_id=? AND entitlement_kind=? AND
19
+ (expires_at IS NULL OR expires_at>?)''',
20
+ (subject, ENTITLEMENT_KIND, wire(now)))[0])
21
+
22
+
23
+ async def manifest_size(app, tx, resource, revision_id=None):
24
+ revision_id = revision_id or resource.revision
25
+ if revision_id is None:
26
+ return 0
27
+ revision = await tx.revision(ResourceRef(id=resource.id, revision=revision_id))
28
+ value = loads(await app.contents.read_bytes(revision.content))
29
+ require(isinstance(value, dict) and isinstance(value.get('entries'), dict),
30
+ 'invalid_hosting_manifest')
31
+ size = 0
32
+ for ref in value['entries'].values():
33
+ item = await tx.revision(decode(ResourceRef, ref))
34
+ size += item.content.size
35
+ require(size <= MAX_CAPACITY_BYTES, 'hosting_capacity_exceeded')
36
+ return size
37
+
38
+
39
+ async def used_capacity(app, tx, owner, *, excluding=None):
40
+ total = 0
41
+ for (rid,) in tx.rows("SELECT id FROM resources WHERE type='website' AND owner=? AND state='active'", (owner,)):
42
+ if rid != excluding:
43
+ total += await manifest_size(app, tx, await tx.resource(rid))
44
+ return total
45
+
46
+
47
+ async def require_capacity(app, tx, resource, proposed_size, now, *, owner=None):
48
+ owner = owner or resource.owner
49
+ used = await used_capacity(app, tx, owner, excluding=resource.id)
50
+ capacity = app.settings.hosting_base_capacity_bytes + extra_capacity(tx, owner, now)
51
+ require(used + proposed_size <= min(capacity, MAX_CAPACITY_BYTES),
52
+ 'hosting_capacity_exceeded')