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,853 @@
1
+ """Reference delivery and durable change feeds, not a workflow engine."""
2
+ from __future__ import annotations
3
+ import hmac
4
+ from dataclasses import replace
5
+ from datetime import UTC,datetime
6
+ from cryptography.exceptions import InvalidTag
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+ from msg.constants import ROOT_SPACE
9
+ from datetime import timedelta
10
+ from msg.core.codec import wire,canonical,loads,decode,digest,parse_time,b64,unb64
11
+ from msg.core.errors import Failure,require
12
+ from msg.core.models import HandlerOutput,ResourceRef,ResourceTypeSpec,EmailSettings,EffectJob,Principal
13
+ from msg.core.requests import signing_bytes
14
+ from msg.plugins.common import *
15
+ from msg.plugins.schemas import *
16
+ from msg.plugins.discovery import visible
17
+ from msg.storage.capacity import require_webhook_capacity
18
+
19
+
20
+ from msg.core.events import event_id
21
+
22
+
23
+ # A deliberately small event vocabulary. An Event is an audit fact, not blanket
24
+ # permission to disclose its data to an external receiver.
25
+ WEBHOOK_DOMAIN_EVENTS = {
26
+ 'content.post_create': 'resource.created',
27
+ 'discussion.reply': 'resource.created',
28
+ 'content.post_edit': 'resource.updated',
29
+ }
30
+
31
+
32
+ def _webhook_subscription_key(subject,resource_id):
33
+ return 'webhook_subscription:'+subject+':'+resource_id
34
+
35
+
36
+ async def enqueue_domain_webhooks(app,tx,event):
37
+ """Project an explicit owner's Event subscription inside the Event transaction."""
38
+ category=WEBHOOK_DOMAIN_EVENTS.get(event.type)
39
+ if category is None:
40
+ return
41
+ for ref in event.resources:
42
+ resource=await tx.resource(ref.id)
43
+ for scope in (resource, await tx.resource(resource.parent) if resource.parent else None):
44
+ if scope is None:
45
+ continue
46
+ owner=scope.owner
47
+ subscription=tx.setting(_webhook_subscription_key(owner,scope.id))
48
+ if not subscription or not subscription.get('enabled') or category not in subscription.get('events',()):
49
+ continue
50
+ endpoint=tx.one('SELECT enabled,generation FROM webhook_endpoints WHERE subject=?',(owner,))
51
+ if not endpoint or endpoint[0]!=1 or endpoint[1]!=subscription['endpoint_generation']:
52
+ continue
53
+ principal=decode(Principal,subscription['principal'])
54
+ # It must remain an owner-controlled subscription, not a delegation
55
+ # that can be retargeted by an event emitted from another account.
56
+ if principal.subject!=owner or principal.actor!=owner or principal.method!='signature':
57
+ continue
58
+ require_webhook_capacity(tx)
59
+ await tx.enqueue(EffectJob(id=new_id('job'),event_id=event.id,kind='webhook',
60
+ dedupe_key=f'{event.id}:{owner}:{scope.id}:{ref.id}:{category}:webhook',
61
+ principal=principal,operation='communication.webhook_subscribe',
62
+ arguments={'recipient_subject':owner,'resource_id':ref.id,'scope_id':scope.id,
63
+ 'category':category,'endpoint_generation':endpoint[1],
64
+ 'subscription_generation':subscription['generation']},
65
+ state='pending',attempts=0,next_attempt_at=event.time,lease_until=None))
66
+
67
+
68
+ def sync_seen_context(subject,sequence,expires_at):
69
+ return canonical({'subject':subject,'seq':sequence,'expires_at':expires_at})
70
+
71
+
72
+ def seal_sync_seen(app,seen,context=b''):
73
+ require(len(seen)<=64,'resync_required')
74
+ raw=canonical(seen)
75
+ require(len(raw)<=4096,'resync_required')
76
+ key=hmac.digest(app.cursors.key,b'msg-sync-seen-v1','sha256')
77
+ nonce_key=hmac.digest(app.cursors.key,b'msg-sync-seen-nonce-v1','sha256')
78
+ nonce=hmac.digest(nonce_key,context+b'\0'+raw,'sha256')[:12]
79
+ return b64(nonce+AESGCM(key).encrypt(nonce,raw,context))
80
+
81
+
82
+ def open_sync_seen(app,value,context=b''):
83
+ payload=unb64(value,limit=8192)
84
+ require(28<=len(payload)<=4124,'resync_required')
85
+ key=hmac.digest(app.cursors.key,b'msg-sync-seen-v1','sha256')
86
+ try:
87
+ seen=loads(AESGCM(key).decrypt(payload[:12],payload[12:],context))
88
+ except InvalidTag as exc:
89
+ raise Failure('invalid_cursor') from exc
90
+ require(type(seen) is list and len(seen)<=64 and
91
+ all(type(rid) is str and len(rid)<=160 for rid in seen) and
92
+ len(set(seen))==len(seen),'resync_required')
93
+ return seen
94
+
95
+
96
+ def sync_checkpoint_token(app,subject,checkpoint_id,version):
97
+ return app.cursors.encode('sync-checkpoint',{'subject':subject},
98
+ {'id':checkpoint_id,'version':version})
99
+
100
+
101
+ def sync_checkpoint_record(app,tx,ctx,token):
102
+ saved=app.cursors.inspect(token)
103
+ require(saved.get('kind')=='sync-checkpoint','cursor_kind_mismatch')
104
+ subject=ctx.principal.subject
105
+ require(saved.get('query')=={'subject':subject},'cursor_principal_mismatch')
106
+ position=saved.get('position')
107
+ require(isinstance(position,dict) and type(position.get('version')) is int and
108
+ isinstance(position.get('id'),str),'invalid_cursor')
109
+ row=tx.one('SELECT subject,credential_id,version,expires_at,body FROM sync_checkpoints WHERE id=?',
110
+ (position['id'],))
111
+ require(row is not None,'resync_required')
112
+ require(row[0]==subject and row[1]==ctx.principal.credential_id and
113
+ ctx.principal.actor==subject and ctx.principal.method=='signature',
114
+ 'cursor_principal_mismatch')
115
+ require(row[2]==position['version'],'checkpoint_conflict')
116
+ require(ctx.now<parse_time(row[3]),'resync_required')
117
+ body=loads(row[4])
118
+ require(type(body.get('seq')) is int and type(body.get('seen')) is list and
119
+ len(body['seen'])<=10000 and len(set(body['seen']))==len(body['seen']) and
120
+ all(type(rid) is str and len(rid)<=160 for rid in body['seen']),
121
+ 'resync_required')
122
+ require(body['seq']>=tx.setting('sync_floor',0),'resync_required')
123
+ return position['id'],row[2],body
124
+
125
+
126
+ def _signed_subject(ctx):
127
+ subject=ctx.principal.subject
128
+ require(subject is not None and ctx.principal.actor==subject and ctx.principal.method=='signature',
129
+ 'signature_required')
130
+ return subject
131
+
132
+
133
+ def _dm_pair(a,b):
134
+ require(a!=b,'dm_self_request')
135
+ participants=tuple(sorted((a,b)))
136
+ return digest(participants),participants
137
+
138
+
139
+ def _dm_record(tx,conversation_id,subject):
140
+ row=tx.one('SELECT pair,participant_a,participant_b,initiator,state FROM dm_conversations WHERE resource_id=?',
141
+ (conversation_id,))
142
+ require(row is not None and subject in row[1:3],'dm_not_found')
143
+ return row
144
+
145
+
146
+ async def direct_ancestor(tx,rid):
147
+ chain=(*await tx.ancestors(rid),await tx.resource(rid))
148
+ for resource in chain:
149
+ if tx.one('SELECT 1 FROM dm_conversations WHERE resource_id=?',(resource.id,)):
150
+ return resource.id
151
+ return None
152
+
153
+
154
+ def _dm_notice(tx,ctx,request,recipient,resource):
155
+ record={'id':new_id('message'),'sender':ctx.principal.subject,'actor':ctx.principal.actor,
156
+ 'recipient':recipient,'resource':wire(ResourceRef(id=resource)),
157
+ 'time':wire(ctx.now),'state':'delivered','source':'dm'}
158
+ tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
159
+ (record['id'],ctx.principal.subject,recipient,resource,
160
+ event_id(request,ctx.principal.subject),canonical(record).decode()),write=True)
161
+
162
+
163
+ def install(app):
164
+ op,finish=registration(app,'communication',('identity','content'))
165
+
166
+ @op('communication.webhook_set',obj({'url':{'type':'string','minLength':1,'maxLength':2048},
167
+ 'secret':{'type':'string','minLength':43,'maxLength':86}},('url','secret')),signature=True)
168
+ async def webhook_set(ctx,request,tx):
169
+ from msg.workers.webhook import seal_secret, validate_endpoint
170
+ subject=_signed_subject(ctx)
171
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
172
+ url=request.arguments['url']
173
+ validate_endpoint(url)
174
+ nonce,ciphertext=seal_secret(app,subject,request.arguments['secret'])
175
+ tx.execute('''INSERT INTO webhook_endpoints (subject,url,nonce,ciphertext,enabled,generation)
176
+ VALUES (?,?,?,?,1,1) ON CONFLICT(subject) DO UPDATE SET url=excluded.url,
177
+ nonce=excluded.nonce,ciphertext=excluded.ciphertext,enabled=1,
178
+ generation=webhook_endpoints.generation+1''',
179
+ (subject,url,nonce,ciphertext),write=True)
180
+ return HandlerOutput(data={'enabled':True,'url':url})
181
+
182
+ @op('communication.webhook_disable',obj(),signature=True)
183
+ async def webhook_disable(ctx,request,tx):
184
+ subject=_signed_subject(ctx)
185
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
186
+ tx.execute('UPDATE webhook_endpoints SET enabled=0,generation=generation+1 WHERE subject=?',
187
+ (subject,),write=True)
188
+ return HandlerOutput(data={'enabled':False})
189
+
190
+ @op('communication.webhook_status',obj(),effect='read')
191
+ async def webhook_status(ctx,request,tx):
192
+ subject=_signed_subject(ctx)
193
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
194
+ row=tx.one('SELECT url,enabled,generation FROM webhook_endpoints WHERE subject=?',(subject,))
195
+ return HandlerOutput(data={'enabled':bool(row and row[1]),'url':row[0] if row else None,
196
+ 'generation':row[2] if row else 0})
197
+
198
+ @op('communication.webhook_subscribe',obj({
199
+ 'resource_id':IDENTIFIER,
200
+ 'events':{'type':'array','items':{'enum':sorted(set(WEBHOOK_DOMAIN_EVENTS.values()))},
201
+ 'minItems':1,'maxItems':2,'uniqueItems':True}},('resource_id','events')),signature=True)
202
+ async def webhook_subscribe(ctx,request,tx):
203
+ owner=_signed_subject(ctx)
204
+ rid=await resolve(tx,request.arguments['resource_id'])
205
+ resource=await tx.resource(rid)
206
+ require(resource.owner==owner and resource.state=='active','webhook_scope_not_owned')
207
+ await check_access(app,ctx,request,tx,rid,'manage')
208
+ require(await app.authorizer.has(ctx.principal,'webhook.domain',operation_id(request),rid,tx),
209
+ 'capability_required')
210
+ row=tx.one('SELECT enabled,generation FROM webhook_endpoints WHERE subject=?',(owner,))
211
+ require(row is not None and row[0]==1,'webhook_disabled')
212
+ key=_webhook_subscription_key(owner,rid)
213
+ previous=tx.setting(key,{})
214
+ events=sorted(request.arguments['events'])
215
+ generation=previous.get('generation',0)+1
216
+ tx.set_setting(key,{'enabled':True,'events':events,'endpoint_generation':row[1],
217
+ 'generation':generation,'principal':wire(ctx.principal)})
218
+ return HandlerOutput(data={'resource_id':rid,'events':events,'enabled':True,
219
+ 'generation':generation})
220
+
221
+ @op('communication.webhook_unsubscribe',obj({'resource_id':IDENTIFIER},('resource_id',)),signature=True)
222
+ async def webhook_unsubscribe(ctx,request,tx):
223
+ owner=_signed_subject(ctx)
224
+ rid=await resolve(tx,request.arguments['resource_id'])
225
+ key=_webhook_subscription_key(owner,rid)
226
+ previous=tx.setting(key,{})
227
+ # Removal is always allowed by the signing owner even after a resource
228
+ # is archived or permissions changed; pending deliveries must stop.
229
+ tx.set_setting(key,{'enabled':False,'events':[],
230
+ 'generation':previous.get('generation',0)+1})
231
+ return HandlerOutput(data={'resource_id':rid,'enabled':False})
232
+
233
+ @op('communication.webhook_subscription',obj({'resource_id':IDENTIFIER},('resource_id',)),effect='read')
234
+ async def webhook_subscription(ctx,request,tx):
235
+ owner=_signed_subject(ctx)
236
+ rid=await resolve(tx,request.arguments['resource_id'])
237
+ record=tx.setting(_webhook_subscription_key(owner,rid),{})
238
+ return HandlerOutput(data={'resource_id':rid,'enabled':bool(record.get('enabled')),
239
+ 'events':record.get('events',[]),
240
+ 'generation':record.get('generation',0)})
241
+
242
+ @op('communication.presence_get',obj({'subject_id':IDENTIFIER},('subject_id',)),effect='read')
243
+ async def presence_get(ctx,request,tx):
244
+ subject=await resolve(tx,request.arguments['subject_id'])
245
+ await tx.subject(subject)
246
+ row=tx.one('SELECT expires_at,body FROM presence WHERE subject=?',(subject,))
247
+ if row is None or parse_time(row[0])<=ctx.now:
248
+ return HandlerOutput(data={'subject_id':subject,'state':'unknown'})
249
+ return HandlerOutput(data=loads(row[1]))
250
+
251
+ @op('communication.presence_set',obj({
252
+ 'state':{'enum':['available','busy','away']},
253
+ 'message':{'type':'string','maxLength':240},
254
+ 'capabilities_hint':{'type':'array','items':{'type':'string','maxLength':80},
255
+ 'maxItems':12,'uniqueItems':True},
256
+ 'ttl':{'type':'integer','minimum':30,'maximum':3600}},('state',)),signature=True)
257
+ async def presence_set(ctx,request,tx):
258
+ subject=_signed_subject(ctx)
259
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
260
+ args=request.arguments
261
+ record={'subject_id':subject,'state':args['state'],'updated_at':wire(ctx.now),
262
+ 'expires_at':wire(ctx.now+timedelta(seconds=args.get('ttl',300))),
263
+ 'self_reported':True}
264
+ if 'message' in args:
265
+ record['message']=args['message']
266
+ if 'capabilities_hint' in args:
267
+ record['capabilities_hint']=args['capabilities_hint']
268
+ tx.execute('''INSERT INTO presence (subject,expires_at,body) VALUES (?,?,?)
269
+ ON CONFLICT(subject) DO UPDATE SET expires_at=excluded.expires_at,body=excluded.body''',
270
+ (subject,record['expires_at'],canonical(record).decode()),write=True)
271
+ return HandlerOutput(data=record)
272
+
273
+ @op('communication.presence_clear',obj(),signature=True)
274
+ async def presence_clear(ctx,request,tx):
275
+ subject=_signed_subject(ctx)
276
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
277
+ tx.execute('DELETE FROM presence WHERE subject=?',(subject,),write=True)
278
+ return HandlerOutput(data={'subject_id':subject,'state':'unknown'})
279
+
280
+ claim_value={'type':['object','array','string','integer','number','boolean','null']}
281
+ evidence_schema={'type':'array','items':REF,'maxItems':16}
282
+
283
+ @op('communication.claim_create',obj({
284
+ 'predicate':{'type':'string','minLength':1,'maxLength':120},
285
+ 'value':claim_value,'expires_at':STRING,'evidence_refs':evidence_schema,
286
+ },('predicate','value')),signature=True)
287
+ async def claim_create(ctx,request,tx):
288
+ from msg.plugins.common import create_resource
289
+ subject=_signed_subject(ctx)
290
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
291
+ args=request.arguments
292
+ require(len(canonical(args))<=8192,'claim_too_large')
293
+ expires=args.get('expires_at')
294
+ if expires is not None:
295
+ ending=parse_time(expires)
296
+ require(ctx.now<ending<=ctx.now+timedelta(days=365),'claim_expiry_invalid')
297
+ refs=[]
298
+ for raw in args.get('evidence_refs',()):
299
+ ref=decode(ResourceRef,raw)
300
+ rid=await resolve(tx,ref.id)
301
+ await check_access(app,ctx,request,tx,rid,'read')
302
+ if ref.revision is not None:
303
+ await tx.revision(ResourceRef(id=rid,revision=ref.revision))
304
+ refs.append(ResourceRef(id=rid,revision=ref.revision))
305
+ try:
306
+ parent=await tx.resolve((await tx.path(subject))+'/claims')
307
+ folder=await tx.resource(parent)
308
+ require(folder.type=='topic' and folder.parent==subject and folder.owner==subject and
309
+ not folder.mode&0o002,'claim_folder_conflict')
310
+ except Failure as exc:
311
+ if exc.code!='not_found':
312
+ raise
313
+ folder=await create_resource(app,ctx,request,tx,parent=subject,type='topic',
314
+ name='claims',mode=0o755)
315
+ parent=folder.id
316
+ rid=new_id('claim')
317
+ assertion={'kind':'self_claim','subject_id':subject,'predicate':args['predicate'],
318
+ 'value':args['value'],'issued_at':wire(ctx.now),'expires_at':expires,
319
+ 'evidence_digest':digest(wire(refs)),'authority':'none'}
320
+ resource=await create_resource(app,ctx,request,tx,parent=parent,type='claim',name=rid,
321
+ body=canonical(assertion),media_type='application/json',
322
+ resource_id=rid,mode=0o444)
323
+ record={**assertion,'id':rid,'evidence_refs':wire(refs),
324
+ 'signature':wire(request.proof.signature),
325
+ 'signed_envelope':b64(signing_bytes(request))}
326
+ tx.execute('INSERT INTO claims (id,subject,issued_at,expires_at,body) VALUES (?,?,?,?,?)',
327
+ (rid,subject,assertion['issued_at'],expires,canonical(record).decode()),write=True)
328
+ return HandlerOutput(resources=(ResourceRef(id=rid,revision=resource.revision),),
329
+ data={'claim_id':rid,'kind':'self_claim','authority':'none'})
330
+
331
+ @op('communication.claim_get',obj({'id':IDENTIFIER},('id',)),effect='read')
332
+ async def claim_get(ctx,request,tx):
333
+ rid=await resolve(tx,request.arguments['id'])
334
+ row=tx.one('SELECT body FROM claims WHERE id=?',(rid,))
335
+ require(row is not None,'claim_not_found')
336
+ await check_access(app,ctx,request,tx,rid,'read')
337
+ record=dict(loads(row[0]))
338
+ refs=[]
339
+ for raw in record.pop('evidence_refs'):
340
+ try:
341
+ if await visible(app,ctx,request,tx,raw['id']):
342
+ refs.append(raw)
343
+ except Failure as exc:
344
+ if exc.code!='not_found':
345
+ raise
346
+ envelope=record.pop('signed_envelope')
347
+ record['evidence_refs']=refs
348
+ if len(refs)==len(loads(row[0])['evidence_refs']):
349
+ record['signed_envelope']=envelope
350
+ return HandlerOutput(data=record)
351
+
352
+ @op('communication.claim_list',obj({'subject_id':IDENTIFIER},('subject_id',)),effect='read')
353
+ async def claim_list(ctx,request,tx):
354
+ subject=await resolve(tx,request.arguments['subject_id'])
355
+ await tx.subject(subject)
356
+ rows=tx.rows('SELECT body FROM claims WHERE subject=? ORDER BY issued_at,id',(subject,))
357
+ items=[]
358
+ for (raw,) in rows:
359
+ claim=loads(raw)
360
+ if await visible(app,ctx,request,tx,claim['id']):
361
+ items.append({key:claim[key] for key in ('id','kind','subject_id','predicate','value',
362
+ 'issued_at','expires_at','evidence_digest','authority')})
363
+ return HandlerOutput(data={'items':items})
364
+
365
+ async def dm_request(ctx,request,tx):
366
+ from msg.plugins.common import create_resource
367
+ sender=_signed_subject(ctx)
368
+ await app.authorizer.require_base(ctx.principal,operation_id(request),sender,tx)
369
+ recipient=await resolve(tx,request.arguments['recipient'])
370
+ target=await tx.resource(recipient)
371
+ require(target.type=='user' and target.state=='active','invalid_recipient')
372
+ await tx.subject(recipient)
373
+ pair,(first,second)=_dm_pair(sender,recipient)
374
+ require(tx.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
375
+ (sender,recipient,recipient,sender)) is None,'dm_blocked')
376
+ row=tx.one('SELECT resource_id,state FROM dm_conversations WHERE pair=?',(pair,))
377
+ if row is not None:
378
+ require(row[1]!='rejected','dm_rejected')
379
+ return HandlerOutput(resources=(ResourceRef(id=row[0]),),
380
+ data={'conversation_id':row[0],'state':row[1],'participant_pair':[first,second]})
381
+ topic=await create_resource(app,ctx,request,tx,parent=ROOT_SPACE,type='topic',
382
+ name='dm-'+new_id('c'),mode=0o700)
383
+ tx.set_setting('policy:'+topic.id,{'post_mode':'0600','editable':True})
384
+ tx.execute('''INSERT INTO dm_conversations
385
+ (pair,resource_id,participant_a,participant_b,initiator,state,created_at,updated_at)
386
+ VALUES (?,?,?,?,?,?,?,?)''',
387
+ (pair,topic.id,first,second,sender,'pending',wire(ctx.now),wire(ctx.now)),write=True)
388
+ introduction=request.arguments.get('introduction')
389
+ intro_ref=None
390
+ if introduction is not None:
391
+ require(len(introduction.encode('utf-8'))<=1024,'introduction_too_large')
392
+ intro=await create_resource(app,ctx,request,tx,parent=topic.id,type='post',
393
+ body=introduction,media_type='text/markdown',mode=0o600)
394
+ intro_ref=ResourceRef(id=intro.id,revision=intro.revision)
395
+ _dm_notice(tx,ctx,request,recipient,topic.id)
396
+ data={'conversation_id':topic.id,'state':'pending',
397
+ 'participant_pair':[first,second]}
398
+ if intro_ref is not None:
399
+ data['introduction_ref']=wire(intro_ref)
400
+ return HandlerOutput(resources=(ResourceRef(id=topic.id),),data=data)
401
+ op('communication.dm_request',obj({'recipient':IDENTIFIER},('recipient',)),
402
+ signature=True)(dm_request)
403
+ op('communication.dm_request',obj({'recipient':IDENTIFIER,
404
+ 'introduction':{'type':'string','minLength':1,'maxLength':500}},
405
+ ('recipient',)),signature=True,version=2)(dm_request)
406
+
407
+ async def dm_decision(ctx,request,tx):
408
+ subject=_signed_subject(ctx)
409
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
410
+ rid=await resolve(tx,request.arguments['conversation_id'])
411
+ pair,first,second,initiator,state=_dm_record(tx,rid,subject)
412
+ require(subject!=initiator,'dm_recipient_required')
413
+ desired='active' if request.operation=='communication.dm_accept' else 'rejected'
414
+ require(state=='pending' or state==desired,'dm_request_closed')
415
+ if state=='pending':
416
+ require(tx.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
417
+ (first,second,second,first)) is None,'dm_blocked')
418
+ tx.execute('UPDATE dm_conversations SET state=?,updated_at=? WHERE pair=? AND state=?',
419
+ (desired,wire(ctx.now),pair,'pending'),write=True)
420
+ _dm_notice(tx,ctx,request,initiator,rid)
421
+ return HandlerOutput(resources=(ResourceRef(id=rid),),
422
+ data={'conversation_id':rid,'state':desired})
423
+ for name in ('communication.dm_accept','communication.dm_reject'):
424
+ op(name,obj({'conversation_id':IDENTIFIER},('conversation_id',)),signature=True)(dm_decision)
425
+
426
+ @op('communication.dm_send',obj({'conversation_id':IDENTIFIER,'body':STRING},
427
+ ('conversation_id','body')),signature=True)
428
+ async def dm_send(ctx,request,tx):
429
+ from msg.plugins.content import create_post
430
+ subject=_signed_subject(ctx)
431
+ rid=await resolve(tx,request.arguments['conversation_id'])
432
+ pair,first,second,initiator,state=_dm_record(tx,rid,subject)
433
+ await check_access(app,ctx,request,tx,rid,'create')
434
+ require(state=='active','dm_not_active')
435
+ post,_=await create_post(app,ctx,request,tx,parent=rid)
436
+ recipient=second if subject==first else first
437
+ _dm_notice(tx,ctx,request,recipient,post.id)
438
+ return output_for(post,conversation_id=rid)
439
+
440
+ @op('communication.dm_list',obj(),effect='read')
441
+ async def dm_list(ctx,request,tx):
442
+ subject=ctx.principal.subject
443
+ require(subject is not None,'authentication_required')
444
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
445
+ rows=tx.rows('''SELECT d.resource_id,d.participant_a,d.participant_b,d.state,d.initiator
446
+ FROM dm_conversations d LEFT JOIN dm_archives a ON a.pair=d.pair AND a.subject=?
447
+ WHERE (d.participant_a=? OR d.participant_b=?) AND a.subject IS NULL ORDER BY d.created_at,d.resource_id''',
448
+ (subject,subject,subject))
449
+ return HandlerOutput(data={'items':[{'conversation_id':rid,'other_subject':b if subject==a else a,
450
+ 'state':state,'initiator':initiator} for rid,a,b,state,initiator in rows]})
451
+
452
+ @op('communication.dm_archive',obj({'conversation_id':IDENTIFIER},('conversation_id',)),signature=True)
453
+ async def dm_archive(ctx,request,tx):
454
+ subject=_signed_subject(ctx)
455
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
456
+ rid=await resolve(tx,request.arguments['conversation_id'])
457
+ pair,*_=_dm_record(tx,rid,subject)
458
+ tx.execute('INSERT INTO dm_archives (subject,pair) VALUES (?,?) ON CONFLICT(subject,pair) DO NOTHING',
459
+ (subject,pair),write=True)
460
+ return HandlerOutput(data={'conversation_id':rid,'archived':True})
461
+
462
+ @op('communication.dm_block',obj({'subject_id':IDENTIFIER},('subject_id',)),signature=True)
463
+ async def dm_block(ctx,request,tx):
464
+ subject=_signed_subject(ctx)
465
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
466
+ other=await resolve(tx,request.arguments['subject_id'])
467
+ await tx.subject(other)
468
+ require(other!=subject,'dm_self_request')
469
+ tx.execute('INSERT INTO dm_blocks (blocker,blocked) VALUES (?,?) ON CONFLICT(blocker,blocked) DO NOTHING',
470
+ (subject,other),write=True)
471
+ return HandlerOutput(data={'subject_id':other,'blocked':True})
472
+
473
+ @op('communication.send',obj({'recipient':IDENTIFIER,'resource':REF},('recipient','resource')))
474
+ async def send(ctx,request,tx):
475
+ recipient=await resolve(tx,request.arguments['recipient'])
476
+ r=await tx.resource(recipient)
477
+ require(r.type in {'user','organization'},'invalid_recipient')
478
+ ref=decode(ResourceRef,request.arguments['resource'])
479
+ await check_access(app,ctx,request,tx,ref.id,'read')
480
+ require((await tx.resource(ref.id)).type!='legacy_directive',
481
+ 'legacy_directive_not_shareable')
482
+ require(await direct_ancestor(tx,ref.id) is None,'dm_reference_private')
483
+ await app.authorizer._ceiling(ctx.principal,operation_id(request),ref.id,tx)
484
+ revision=await tx.revision(ref)
485
+ ref=ResourceRef(id=ref.id,revision=revision.id)
486
+ record={'id':new_id('message'),'sender':ctx.principal.subject,'actor':ctx.principal.actor,
487
+ 'recipient':recipient,'resource':wire(ref),'time':wire(ctx.now),'state':'delivered'}
488
+ eid=event_id(request,ctx.principal.subject)
489
+ tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
490
+ (record['id'],ctx.principal.subject,recipient,ref.id,eid,canonical(record).decode()),write=True)
491
+ if r.type=='user':
492
+ row=tx.one('SELECT enabled,generation FROM webhook_endpoints WHERE subject=?',(recipient,))
493
+ if row and row[0]:
494
+ require_webhook_capacity(tx)
495
+ await tx.enqueue(EffectJob(id=new_id('job'),event_id=eid,kind='webhook',
496
+ dedupe_key=f'{eid}:{recipient}:webhook',principal=ctx.principal,
497
+ operation=request.operation,arguments={'recipient_subject':recipient,
498
+ 'message_id':record['id'],'endpoint_generation':row[1]},
499
+ state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None))
500
+ # Notification is an external projection; it never includes private body content.
501
+ if r.type=='user' and app.settings.server.mail:
502
+ row=tx.one('SELECT body FROM emails WHERE subject=?',(recipient,))
503
+ if row:
504
+ email=decode(EmailSettings,loads(row[0]))
505
+ if email.verified_at and 'communication.send' in email.enabled_events:
506
+ await tx.enqueue(EffectJob(id=new_id('job'),event_id=eid,kind='mail',
507
+ dedupe_key=f'{eid}:{recipient}:mail',principal=ctx.principal,operation=request.operation,
508
+ arguments={'recipient':email.address,'recipient_subject':recipient,'subject':'New msg reference',
509
+ 'text':app.settings.service_url+'/_id/'+ref.id},state='pending',attempts=0,
510
+ next_attempt_at=ctx.now,lease_until=None))
511
+ return HandlerOutput(resources=(ref,),data={'message_id':record['id'],'recipient':recipient})
512
+
513
+ async def watch(ctx,request,tx):
514
+ rid=await resolve(tx,request.arguments['id'])
515
+ await check_access(app,ctx,request,tx,rid,'read')
516
+ await app.authorizer._ceiling(ctx.principal,operation_id(request),rid,tx)
517
+ if request.operation=='communication.watch':
518
+ tx.execute('INSERT OR IGNORE INTO watches VALUES (?,?)',(ctx.principal.subject,rid),write=True)
519
+ else:
520
+ tx.execute('DELETE FROM watches WHERE subject=? AND resource=?',(ctx.principal.subject,rid),write=True)
521
+ return HandlerOutput(resources=(ResourceRef(id=rid),),data={'watching':request.operation=='communication.watch'})
522
+ for name in ('communication.watch','communication.unwatch'):
523
+ op(name,obj({'id':IDENTIFIER},('id',)))(watch)
524
+
525
+ async def mailbox(ctx,request,tx):
526
+ from msg.plugins.discovery import next_link
527
+ require(ctx.principal.subject is not None,'authentication_required')
528
+ await app.authorizer.require_base(ctx.principal,operation_id(request),ctx.principal.subject,tx)
529
+ inbox=request.operation=='communication.inbox'
530
+ subjects=[ctx.principal.subject]
531
+ if inbox:
532
+ subjects += [m.organization_id for m in await tx.memberships(ctx.principal.subject)]
533
+ binding=digest({'subject':ctx.principal.subject,'groups':sorted(subjects)})
534
+ after=app.cursors.decode(request.arguments['cursor'],request.operation,binding) if request.arguments.get('cursor') else ''
535
+ placeholders=','.join('?' for _ in subjects)
536
+ column='recipient' if inbox else 'sender'
537
+ rows=tx.execute(f'SELECT id,resource,body FROM messages WHERE {column} IN ({placeholders}) AND id>? ORDER BY id',(*subjects,after))
538
+ items=[];limit=request.arguments.get('limit',50);more=False
539
+ for id,rid,raw in rows:
540
+ if await visible(app,ctx,request,tx,rid):
541
+ if len(items)==limit:
542
+ more=True;break
543
+ items.append(loads(raw));after=id
544
+ data={'items':items}
545
+ if more:
546
+ cursor=app.cursors.encode(request.operation,binding,after)
547
+ data.update(cursor=cursor,next=next_link(app,request.operation,{**request.arguments,'cursor':cursor}),next_requires_auth=True)
548
+ return HandlerOutput(data=data)
549
+ for name in ('communication.inbox','communication.outbox'):
550
+ op(name,obj({'limit':{'type':'integer','minimum':1,'maximum':200},'cursor':STRING}),effect='read')(mailbox)
551
+
552
+ @op('communication.changes',obj({'cursor':STRING,'limit':{'type':'integer','minimum':1,'maximum':200}}),effect='read')
553
+ async def changes(ctx,request,tx):
554
+ require(ctx.principal.subject is not None,'authentication_required')
555
+ await app.authorizer.require_base(ctx.principal,operation_id(request),ctx.principal.subject,tx)
556
+ subject=ctx.principal.subject
557
+ cursor=request.arguments.get('cursor')
558
+ watches={r[0] for r in tx.rows('SELECT resource FROM watches WHERE subject=?',(subject,))}
559
+ epoch=tx.setting('authorization_epoch',0)
560
+ watch_digest=digest(sorted(watches))
561
+ saved=app.cursors.decode(cursor,'sync',subject) if cursor else None
562
+ if saved is not None:
563
+ require(isinstance(saved,dict) and saved.get('authorization_epoch')==epoch and
564
+ saved.get('watch_digest')==watch_digest,'resync_required')
565
+ position=saved['seq'] if saved else tx.setting('sync_floor',0)
566
+ require(type(position) is int and position>=tx.setting('sync_floor',0),'resync_required')
567
+ items=[]
568
+ limit=request.arguments.get('limit',50)
569
+ for seq,raw in tx.execute('SELECT seq,body FROM events WHERE seq>? ORDER BY seq',(position,)):
570
+ position=seq
571
+ event=loads(raw)
572
+ if event['type'].startswith('topic.') and event.get('data',{}).get('reason'):
573
+ from msg.plugins.content import topic_admin
574
+ topic=event['data'].get('topic_id')
575
+ if topic is None or not topic_admin(tx,topic,subject):
576
+ event['data']=dict(event['data'])
577
+ event['data'].pop('reason',None)
578
+ references=event['resources']
579
+ permitted=[]
580
+ for ref in references:
581
+ if await visible(app,ctx,request,tx,ref['id']):
582
+ permitted.append(ref)
583
+ relevant=event['subject']==subject or event['subject'] in watches
584
+ if event['type'].startswith('topic.'):
585
+ topic=event.get('data',{}).get('topic_id')
586
+ member=tx.one("SELECT 1 FROM topic_memberships WHERE topic=? AND subject=? AND status='active'",
587
+ (topic,subject)) if topic else None
588
+ relevant=relevant or member is not None
589
+ for ref in permitted:
590
+ relevant=relevant or ref['id'] in watches or any(r.id in watches for r in await tx.ancestors(ref['id']))
591
+ direct=await direct_ancestor(tx,ref['id'])
592
+ if direct is not None:
593
+ relevant=True
594
+ if relevant and (permitted or not references and event['subject']==subject):
595
+ event['resources']=permitted
596
+ items.append({'seq':seq,**event})
597
+ if len(items)>=limit:
598
+ break
599
+ return HandlerOutput(data={'items':items,'sync_cursor':app.cursors.encode('sync',subject,{'seq':position,'authorization_epoch':epoch,'watch_digest':watch_digest})})
600
+
601
+ def sync_authority(tx,subject):
602
+ watches={row[0] for row in tx.rows('SELECT resource FROM watches WHERE subject=?',(subject,))}
603
+ return (watches,digest(sorted(watches)),tx.setting('authorization_epoch',0),
604
+ digest([tuple(row) for row in tx.rows(
605
+ 'SELECT topic,role,status FROM topic_memberships WHERE subject=? ORDER BY topic',(subject,))]))
606
+
607
+ @op('communication.sync_checkpoint_open',obj({'cursor':STRING},('cursor',)),signature=True)
608
+ async def sync_checkpoint_open(ctx,request,tx):
609
+ subject=_signed_subject(ctx)
610
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
611
+ saved=app.cursors.inspect(request.arguments['cursor'])
612
+ require(saved.get('kind')=='sync-v2' and saved.get('query')=={'subject':subject},
613
+ 'cursor_kind_mismatch')
614
+ position=saved.get('position')
615
+ require(position.get('principal')=={'actor':subject,'subject':subject,
616
+ 'credential_id':ctx.principal.credential_id},'cursor_principal_mismatch')
617
+ watches,watch_hash,epoch,topic_hash=sync_authority(tx,subject)
618
+ require(position.get('watch_digest')==watch_hash and
619
+ position.get('authorization_epoch')==epoch and
620
+ position.get('topic_membership_digest')==topic_hash and
621
+ ctx.now<parse_time(position['expires_at']) and
622
+ type(position.get('seq')) is int and
623
+ position['seq']>=tx.setting('sync_floor',0),'resync_required')
624
+ seen=open_sync_seen(app,position.get('seen_ciphertext',''),
625
+ sync_seen_context(subject,position['seq'],position['expires_at']))
626
+ checkpoint_id=new_id('sync')
627
+ expires_at=wire(ctx.now+timedelta(days=30))
628
+ body={'seq':position['seq'],'seen':seen,'watch_digest':watch_hash,
629
+ 'authorization_epoch':epoch,'topic_membership_digest':topic_hash}
630
+ tx.execute('INSERT INTO sync_checkpoints VALUES (?,?,?,?,?,?)',
631
+ (checkpoint_id,subject,ctx.principal.credential_id,0,expires_at,
632
+ canonical(body).decode()),write=True)
633
+ cursor=sync_checkpoint_token(app,subject,checkpoint_id,0)
634
+ return HandlerOutput(data={'sync_cursor':cursor,'next':'/_r/s/'+cursor,
635
+ 'next_requires_auth':True,'expires_at':expires_at})
636
+
637
+ async def checkpoint_delta(ctx,request,tx,body,limit):
638
+ subject=ctx.principal.subject
639
+ # ACK must recompute precisely the same visibility decision as GET.
640
+ request=replace(request,operation='communication.sync')
641
+ watches,watch_hash,epoch,topic_hash=sync_authority(tx,subject)
642
+ require(body['watch_digest']==watch_hash,'resync_required')
643
+ stale=(body['authorization_epoch']!=epoch or
644
+ body['topic_membership_digest']!=topic_hash)
645
+ sequence=body['seq']
646
+ seen=list(body['seen'])
647
+ items=[]
648
+ async def can_read(rid):
649
+ try:
650
+ return await visible(app,ctx,request,tx,rid)
651
+ except Failure as exc:
652
+ if exc.code in {'not_found','resource_purged','ancestor_inactive'}:
653
+ return False
654
+ raise
655
+ for rid in tuple(seen):
656
+ if not await can_read(rid):
657
+ items.append({'kind':'revoked','ref':{'id':rid}})
658
+ seen.remove(rid)
659
+ if len(items)>=limit:
660
+ break
661
+ if stale:
662
+ # Known losses can be drained over several acknowledged pages. A
663
+ # permission gain may expose older events, so no event sequence is
664
+ # advanced until the client rebuilds a fresh visible baseline.
665
+ if not items:
666
+ raise Failure('resync_required')
667
+ elif len(items)<limit:
668
+ scanned=0
669
+ for seq,raw in tx.execute('SELECT seq,body FROM events WHERE seq>? ORDER BY seq',(sequence,)):
670
+ scanned+=1
671
+ require(scanned<=5000,'resync_required')
672
+ event=loads(raw)
673
+ candidates=[]
674
+ for ref in event['resources']:
675
+ rid=ref['id']
676
+ if not await can_read(rid):
677
+ continue
678
+ relevant=(event['subject']==subject or rid in watches or
679
+ any(ancestor.id in watches for ancestor in await tx.ancestors(rid)))
680
+ if not relevant:
681
+ direct=await direct_ancestor(tx,rid)
682
+ if direct is not None:
683
+ pair=tx.one('SELECT participant_a,participant_b FROM dm_conversations '
684
+ 'WHERE resource_id=?',(direct,))
685
+ relevant=pair is not None and subject in pair
686
+ if not relevant:
687
+ continue
688
+ kind=('archived' if event['type'] in {'content.archive','content.purge'} else
689
+ 'created' if event['type'] in {'content.post_create','content.topic_create',
690
+ 'discussion.reply','discussion.quote'} else 'modified')
691
+ candidates.append({'seq':seq,'kind':kind,'event_type':event['type'],'ref':ref})
692
+ require(len(candidates)<=limit,'resync_required')
693
+ if len(items)+len(candidates)>limit:
694
+ break
695
+ for item in candidates:
696
+ rid=item['ref']['id']
697
+ if rid not in seen:
698
+ require(len(seen)<10000,'resync_required')
699
+ seen.append(rid)
700
+ items.extend(candidates)
701
+ sequence=seq
702
+ return items,{**body,'seq':sequence,'seen':seen},stale
703
+
704
+ @op('communication.sync_checkpoint_ack',obj({'ack':STRING},('ack',)),signature=True)
705
+ async def sync_checkpoint_ack(ctx,request,tx):
706
+ subject=_signed_subject(ctx)
707
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
708
+ proof=app.cursors.inspect(request.arguments['ack'])
709
+ require(proof.get('kind')=='sync-checkpoint-ack' and
710
+ proof.get('query')=={'subject':subject},'invalid_cursor')
711
+ position=proof.get('position')
712
+ require(isinstance(position,dict) and type(position.get('limit')) is int and
713
+ 1<=position['limit']<=100,'invalid_cursor')
714
+ current=sync_checkpoint_token(app,subject,position['id'],position['version'])
715
+ checkpoint_id,version,body=sync_checkpoint_record(app,tx,ctx,current)
716
+ items,updated,stale=await checkpoint_delta(ctx,request,tx,body,position['limit'])
717
+ require(digest({'items':items,'state':updated,'stale':stale})==position['digest'],
718
+ 'checkpoint_stale')
719
+ expires_at=wire(ctx.now+timedelta(days=30))
720
+ result=tx.execute('UPDATE sync_checkpoints SET version=?,expires_at=?,body=? WHERE id=? AND version=?',
721
+ (version+1,expires_at,canonical(updated).decode(),checkpoint_id,version),write=True)
722
+ require(result.rowcount==1,'checkpoint_conflict')
723
+ cursor=sync_checkpoint_token(app,subject,checkpoint_id,version+1)
724
+ return HandlerOutput(data={'sync_cursor':cursor,'next':'/_r/s/'+cursor,
725
+ 'next_requires_auth':True,'resync_required':stale,
726
+ 'expires_at':expires_at})
727
+
728
+ @op('communication.sync',obj({'cursor':STRING,
729
+ 'limit':{'type':'integer','minimum':1,'maximum':100}}),effect='read')
730
+ async def sync(ctx,request,tx):
731
+ subject=ctx.principal.subject
732
+ require(subject is not None,'authentication_required')
733
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
734
+ if request.arguments.get('cursor'):
735
+ kind=app.cursors.inspect(request.arguments['cursor']).get('kind')
736
+ if kind=='sync-checkpoint':
737
+ token=request.arguments['cursor']
738
+ checkpoint_id,version,body=sync_checkpoint_record(app,tx,ctx,token)
739
+ limit=request.arguments.get('limit',50)
740
+ items,updated,stale=await checkpoint_delta(ctx,request,tx,body,limit)
741
+ ack=app.cursors.encode('sync-checkpoint-ack',{'subject':subject},
742
+ {'id':checkpoint_id,'version':version,'limit':limit,
743
+ 'digest':digest({'items':items,'state':updated,'stale':stale})})
744
+ return HandlerOutput(data={'items':items,'sync_cursor':token,
745
+ 'checkpoint_ack':ack,'resync_required':stale})
746
+ principal={'actor':ctx.principal.actor,'subject':subject,
747
+ 'credential_id':ctx.principal.credential_id}
748
+ watches={row[0] for row in tx.rows('SELECT resource FROM watches WHERE subject=?',(subject,))}
749
+ watch_hash=digest(sorted(watches))
750
+ authorization_epoch=tx.setting('authorization_epoch',0)
751
+ topic_membership_hash=digest([tuple(row) for row in tx.rows(
752
+ 'SELECT topic,role,status FROM topic_memberships WHERE subject=? ORDER BY topic',(subject,))])
753
+ floor=tx.setting('sync_floor',0)
754
+ if request.arguments.get('cursor'):
755
+ saved=app.cursors.inspect(request.arguments['cursor'])
756
+ require(saved.get('kind')=='sync-v2','cursor_kind_mismatch')
757
+ require(saved.get('query')=={'subject':subject},'cursor_principal_mismatch')
758
+ position=saved['position']
759
+ require(position.get('principal')==principal,'cursor_principal_mismatch')
760
+ require(position.get('watch_digest')==watch_hash,'resync_required')
761
+ require(ctx.now<parse_time(position['expires_at']),'resync_required')
762
+ require(type(position.get('seq')) is int and position['seq']>=floor,
763
+ 'resync_required')
764
+ sequence=position['seq']
765
+ context=sync_seen_context(subject,position['seq'],position['expires_at'])
766
+ seen=open_sync_seen(app,position.get('seen_ciphertext',''),context)
767
+ expires_at=parse_time(position['expires_at'])
768
+ else:
769
+ sequence=floor
770
+ seen=[]
771
+ minute=int(ctx.now.timestamp()//60)
772
+ expires_at=datetime.fromtimestamp((minute+15)*60,UTC)
773
+ limit=request.arguments.get('limit',50)
774
+ items=[]
775
+ async def can_read(rid):
776
+ try:
777
+ return await visible(app,ctx,request,tx,rid)
778
+ except Failure as exc:
779
+ if exc.code in {'not_found','resource_purged','ancestor_inactive'}:
780
+ return False
781
+ raise
782
+ if request.arguments.get('cursor') and (
783
+ position.get('authorization_epoch')!=authorization_epoch or
784
+ position.get('topic_membership_digest')!=topic_membership_hash
785
+ ):
786
+ # A permission gain can expose events before the saved sequence. A
787
+ # cursor cannot prove that replay is complete, so never advance it.
788
+ # Known losses may still be reported without exposing event content.
789
+ revoked=[{'kind':'revoked','ref':{'id':rid}} for rid in seen
790
+ if not await can_read(rid)]
791
+ if revoked:
792
+ return HandlerOutput(data={'items':revoked[:limit],
793
+ 'resync_required':True})
794
+ raise Failure('resync_required')
795
+ for rid in tuple(seen):
796
+ if not await can_read(rid):
797
+ if len(items)==limit:break
798
+ items.append({'kind':'revoked','ref':{'id':rid}})
799
+ seen.remove(rid)
800
+ if len(items)<limit:
801
+ scanned=0
802
+ for seq,raw in tx.execute('SELECT seq,body FROM events WHERE seq>? ORDER BY seq',(sequence,)):
803
+ scanned+=1
804
+ require(scanned<=5000,'resync_required')
805
+ event=loads(raw)
806
+ candidates=[]
807
+ for ref in event['resources']:
808
+ rid=ref['id']
809
+ if not await can_read(rid):
810
+ continue
811
+ relevant=(event['subject']==subject or rid in watches or
812
+ any(ancestor.id in watches for ancestor in await tx.ancestors(rid)))
813
+ if not relevant:
814
+ direct=await direct_ancestor(tx,rid)
815
+ if direct is not None:
816
+ pair=tx.one('SELECT participant_a,participant_b FROM dm_conversations '
817
+ 'WHERE resource_id=?',(direct,))
818
+ relevant=pair is not None and subject in pair
819
+ if not relevant:
820
+ continue
821
+ kind=('archived' if event['type'] in {'content.archive','content.purge'} else
822
+ 'created' if event['type'] in {'content.post_create','content.topic_create',
823
+ 'discussion.reply','discussion.quote'} else 'modified')
824
+ candidates.append({'seq':seq,'kind':kind,'event_type':event['type'],
825
+ 'ref':ref})
826
+ require(len(candidates)<=limit,'resync_required')
827
+ if len(items)+len(candidates)>limit:
828
+ break
829
+ for item in candidates:
830
+ rid=item['ref']['id']
831
+ if rid not in seen:
832
+ require(len(seen)<64,'resync_required')
833
+ seen.append(rid)
834
+ items.extend(candidates)
835
+ sequence=seq
836
+ expiration=wire(expires_at)
837
+ context=sync_seen_context(subject,sequence,expiration)
838
+ token=app.cursors.encode('sync-v2',{'subject':subject},
839
+ {'seq':sequence,'seen_ciphertext':seal_sync_seen(app,seen,context),
840
+ 'principal':principal,'watch_digest':watch_hash,
841
+ 'authorization_epoch':authorization_epoch,
842
+ 'topic_membership_digest':topic_membership_hash,
843
+ 'expires_at':expiration})
844
+ require(len('/_r/s/'+token)<=min(8192,app.settings.server.limits.max_path_bytes),
845
+ 'resync_required')
846
+ return HandlerOutput(data={'items':items,'sync_cursor':token,'next':'/_r/s/'+token,
847
+ 'next_requires_auth':True})
848
+ from msg.plugins.collaboration import install as install_collaboration
849
+ install_collaboration(app, op)
850
+ from msg.plugins.following import install as install_following
851
+ install_following(app, op)
852
+ finish((ResourceTypeSpec(name='claim',version=1,container=False,content_schema=None,
853
+ operations=frozenset(),relations=frozenset()),))