msgctl 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
|
@@ -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()),))
|