@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
"""Fixed, in-process Team Hub adapter for authenticated secure peers.
|
|
2
|
+
|
|
3
|
+
The TLS gateway authenticates and authorizes the peer before constructing a
|
|
4
|
+
``ProxyRequest``. This adapter never accepts a bearer token and never makes an
|
|
5
|
+
HTTP request: it maps the small V1 allowlist directly onto ``HubStore`` using a
|
|
6
|
+
synthetic, live-checked automation principal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from functools import wraps
|
|
13
|
+
import json
|
|
14
|
+
from collections import deque
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
from urllib.parse import parse_qsl, quote
|
|
22
|
+
|
|
23
|
+
from .auth import _identity
|
|
24
|
+
from .secure_peer import (
|
|
25
|
+
AttachmentFileLease,
|
|
26
|
+
AttachmentProxyRequest,
|
|
27
|
+
AttachmentProxyResponse,
|
|
28
|
+
PeerAuthorization,
|
|
29
|
+
ProxyRequest,
|
|
30
|
+
ProxyResponse,
|
|
31
|
+
SecurePeerError,
|
|
32
|
+
)
|
|
33
|
+
from .security import canonical_json
|
|
34
|
+
from .mail_hint_streams import MailHintLease, owned_mail_snapshot
|
|
35
|
+
from .mail_hints import MailHintCapacity, MailHintClosed
|
|
36
|
+
from .notification_hints import NotificationLease, owned_notification_snapshot
|
|
37
|
+
from .store import MAX_NETWORK_BODY_BYTES, HubError, HubStore
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_TEAM_PREFIX = "/v1/teams/"
|
|
41
|
+
_CHANNEL_PREFIX = "/v1/channels/"
|
|
42
|
+
_MESSAGE_SUFFIX = "/messages"
|
|
43
|
+
_NETWORK_CHILD = "network"
|
|
44
|
+
_CONTENT_RANGE_RE = re.compile(
|
|
45
|
+
r"^bytes (?P<start>[0-9]{1,15})-(?P<end>[0-9]{1,15})/"
|
|
46
|
+
r"(?P<total>[0-9]{1,15})$"
|
|
47
|
+
)
|
|
48
|
+
_RANGE_RE = re.compile(r"^bytes=(?P<start>[0-9]{0,15})-(?P<end>[0-9]{0,15})$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mail_hint_errors(operation):
|
|
52
|
+
"""Keep pre-header Mail failures typed at the authenticated gateway seam."""
|
|
53
|
+
@wraps(operation)
|
|
54
|
+
def run(*args, **kwargs):
|
|
55
|
+
try:
|
|
56
|
+
return operation(*args, **kwargs)
|
|
57
|
+
except HubError as exc:
|
|
58
|
+
raise SecurePeerError(exc.code, str(exc), exc.status_code) from exc
|
|
59
|
+
except MailHintCapacity as exc:
|
|
60
|
+
raise SecurePeerError("rate_limited", str(exc), 429) from exc
|
|
61
|
+
except MailHintClosed as exc:
|
|
62
|
+
raise SecurePeerError("forbidden", str(exc), 403) from exc
|
|
63
|
+
return run
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SecurePeerHubAdapter:
|
|
67
|
+
"""Translate the gateway's exact allowlist into Team Hub store calls."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, store: HubStore) -> None:
|
|
70
|
+
self.store = store
|
|
71
|
+
self._rate_lock = threading.Lock()
|
|
72
|
+
self._rate_condition = threading.Condition(self._rate_lock)
|
|
73
|
+
self._rate_events: dict[tuple[str, str], deque[float]] = {}
|
|
74
|
+
self._in_flight: dict[str, int] = {}
|
|
75
|
+
self._revoking: set[str] = set()
|
|
76
|
+
self._stream_aborters: dict[str, set[Callable[[], None]]] = {}
|
|
77
|
+
# Passive Mail leases never retain _in_flight or ordinary rate slots.
|
|
78
|
+
self._mail_leases: dict[str, set[MailHintLease | NotificationLease | object]] = {}
|
|
79
|
+
|
|
80
|
+
@_mail_hint_errors
|
|
81
|
+
def team_mail_hint_snapshot(self, peer: PeerAuthorization, previous_cursor=None, *, version: int = 1) -> dict[str, Any]:
|
|
82
|
+
self._admit(peer.peer_id, write=False)
|
|
83
|
+
try:
|
|
84
|
+
owned = owned_notification_snapshot if version == 2 else owned_mail_snapshot
|
|
85
|
+
snapshot, _retained = owned(
|
|
86
|
+
self.store, self._claims(self.store, peer), peer.team_id, previous_cursor,
|
|
87
|
+
)
|
|
88
|
+
return {"hub_id": self.store.hub_id, "cursor": snapshot}
|
|
89
|
+
finally:
|
|
90
|
+
self._release(peer.peer_id)
|
|
91
|
+
|
|
92
|
+
@_mail_hint_errors
|
|
93
|
+
def subscribe_team_mail_hints(self, peer: PeerAuthorization, previous_cursor=None, *,
|
|
94
|
+
authority_guard: Callable[[], None] | None = None,
|
|
95
|
+
version: int = 1) -> MailHintLease | NotificationLease:
|
|
96
|
+
token = object()
|
|
97
|
+
with self._rate_condition:
|
|
98
|
+
current = self._mail_leases.get(peer.peer_id, set())
|
|
99
|
+
if peer.peer_id in self._revoking:
|
|
100
|
+
raise MailHintClosed("Secure peer authorization is being revoked")
|
|
101
|
+
if len(current) >= 2 or sum(map(len, self._mail_leases.values())) >= 64:
|
|
102
|
+
raise HubError("rate_limited", "Mail stream capacity reached", 429)
|
|
103
|
+
self._mail_leases.setdefault(peer.peer_id, set()).add(token)
|
|
104
|
+
subscription = None
|
|
105
|
+
lease = None
|
|
106
|
+
try:
|
|
107
|
+
claims = self._claims(self.store, peer)
|
|
108
|
+
owned = owned_notification_snapshot if version == 2 else owned_mail_snapshot
|
|
109
|
+
_owned, retained = owned(self.store, claims, peer.team_id, previous_cursor)
|
|
110
|
+
subscriber = self.store.subscribe_team_notifications if version == 2 else self.store.subscribe_team_mail_arrivals
|
|
111
|
+
subscription, snapshot = subscriber(
|
|
112
|
+
claims, peer.team_id, previous_cursor=retained,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@_mail_hint_errors
|
|
116
|
+
def authorize() -> None:
|
|
117
|
+
if authority_guard is not None:
|
|
118
|
+
authority_guard()
|
|
119
|
+
with self._rate_condition:
|
|
120
|
+
if peer.peer_id in self._revoking or lease not in self._mail_leases.get(peer.peer_id, ()):
|
|
121
|
+
raise MailHintClosed("Secure peer Mail authority changed")
|
|
122
|
+
live = self.store.team_mail_arrival_snapshot(
|
|
123
|
+
self._claims(self.store, peer), peer.team_id,
|
|
124
|
+
)
|
|
125
|
+
mail_snapshot = snapshot["mail"] if version == 2 else snapshot
|
|
126
|
+
if live["recipient_server_id"] != mail_snapshot["recipient_server_id"]:
|
|
127
|
+
raise MailHintClosed("Secure peer Mail binding changed")
|
|
128
|
+
|
|
129
|
+
def retired() -> None:
|
|
130
|
+
with self._rate_condition:
|
|
131
|
+
members = self._mail_leases.get(peer.peer_id)
|
|
132
|
+
if members is not None:
|
|
133
|
+
members.discard(lease)
|
|
134
|
+
if not members:
|
|
135
|
+
self._mail_leases.pop(peer.peer_id, None)
|
|
136
|
+
self._rate_condition.notify_all()
|
|
137
|
+
|
|
138
|
+
lease_type = NotificationLease if version == 2 else MailHintLease
|
|
139
|
+
lease = lease_type(
|
|
140
|
+
subscription, snapshot, hub_id=self.store.hub_id, authorize=authorize,
|
|
141
|
+
expires_at=peer.certificate_expires_at, on_close=retired,
|
|
142
|
+
)
|
|
143
|
+
with self._rate_condition:
|
|
144
|
+
members = self._mail_leases[peer.peer_id]
|
|
145
|
+
members.discard(token)
|
|
146
|
+
members.add(lease)
|
|
147
|
+
self._rate_condition.notify_all()
|
|
148
|
+
lease.revalidate()
|
|
149
|
+
return lease
|
|
150
|
+
except BaseException:
|
|
151
|
+
if lease is not None:
|
|
152
|
+
lease.close()
|
|
153
|
+
elif subscription is not None:
|
|
154
|
+
subscription.close()
|
|
155
|
+
with self._rate_condition:
|
|
156
|
+
members = self._mail_leases.get(peer.peer_id)
|
|
157
|
+
if members is not None:
|
|
158
|
+
members.discard(token)
|
|
159
|
+
if not members:
|
|
160
|
+
self._mail_leases.pop(peer.peer_id, None)
|
|
161
|
+
self._rate_condition.notify_all()
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
def close_mail_hint_streams(self, peer_id: str | None = None) -> None:
|
|
165
|
+
with self._rate_condition:
|
|
166
|
+
leases = tuple(
|
|
167
|
+
item for key, members in self._mail_leases.items()
|
|
168
|
+
if peer_id is None or key == peer_id
|
|
169
|
+
for item in members if isinstance(item, (MailHintLease, NotificationLease))
|
|
170
|
+
)
|
|
171
|
+
for lease in leases:
|
|
172
|
+
lease.close()
|
|
173
|
+
|
|
174
|
+
def _admit(
|
|
175
|
+
self, peer_id: str, *, write: bool, attachment: bool = False
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Bound authenticated-peer CPU/concurrency before touching SQLite."""
|
|
178
|
+
|
|
179
|
+
now = time.monotonic()
|
|
180
|
+
with self._rate_condition:
|
|
181
|
+
if peer_id in self._revoking:
|
|
182
|
+
raise HubError(
|
|
183
|
+
"forbidden", "Secure peer authorization is being revoked", 403
|
|
184
|
+
)
|
|
185
|
+
in_flight = self._in_flight.get(peer_id, 0)
|
|
186
|
+
if in_flight >= 4:
|
|
187
|
+
raise HubError(
|
|
188
|
+
"rate_limited",
|
|
189
|
+
"Secure peer has too many concurrent requests",
|
|
190
|
+
429,
|
|
191
|
+
)
|
|
192
|
+
limits = (
|
|
193
|
+
(("attachment", 1_200),)
|
|
194
|
+
if attachment
|
|
195
|
+
else (("all", 240), ("write", 60))
|
|
196
|
+
)
|
|
197
|
+
for kind, limit in limits:
|
|
198
|
+
if kind == "write" and not write:
|
|
199
|
+
continue
|
|
200
|
+
key = (peer_id, kind)
|
|
201
|
+
events = self._rate_events.setdefault(key, deque())
|
|
202
|
+
while events and events[0] <= now - 60.0:
|
|
203
|
+
events.popleft()
|
|
204
|
+
if len(events) >= limit:
|
|
205
|
+
raise HubError(
|
|
206
|
+
"rate_limited",
|
|
207
|
+
"Secure peer request limit exceeded",
|
|
208
|
+
429,
|
|
209
|
+
)
|
|
210
|
+
events.append(now)
|
|
211
|
+
self._in_flight[peer_id] = in_flight + 1
|
|
212
|
+
|
|
213
|
+
def _release(self, peer_id: str) -> None:
|
|
214
|
+
with self._rate_condition:
|
|
215
|
+
remaining = self._in_flight.get(peer_id, 0) - 1
|
|
216
|
+
if remaining > 0:
|
|
217
|
+
self._in_flight[peer_id] = remaining
|
|
218
|
+
else:
|
|
219
|
+
self._in_flight.pop(peer_id, None)
|
|
220
|
+
self._rate_condition.notify_all()
|
|
221
|
+
|
|
222
|
+
def provision_peer(self, peer: dict[str, Any], *, display_name: str) -> str:
|
|
223
|
+
"""Bind an approved peer once, before request-time read-only claims."""
|
|
224
|
+
|
|
225
|
+
return self.store.ensure_secure_peer_service(
|
|
226
|
+
peer_id=str(peer["peer_id"]),
|
|
227
|
+
peer_server_identity=str(peer["peer_server_identity"]),
|
|
228
|
+
team_id=str(peer["team_id"]),
|
|
229
|
+
display_name=display_name,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def preflight_team(self, team_id: str) -> None:
|
|
233
|
+
self.store.require_secure_peer_target_team(team_id)
|
|
234
|
+
|
|
235
|
+
def active_binding_peer_ids(
|
|
236
|
+
self,
|
|
237
|
+
peer_ids: list[str] | tuple[str, ...],
|
|
238
|
+
peer_server_identity: str,
|
|
239
|
+
) -> set[str]:
|
|
240
|
+
return self.store.active_secure_peer_binding_ids(
|
|
241
|
+
peer_ids,
|
|
242
|
+
peer_server_identity,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def record_peer_heartbeat(self, peer_id: str, team_id: str) -> None:
|
|
246
|
+
self.store.record_secure_peer_heartbeat(peer_id, team_id)
|
|
247
|
+
|
|
248
|
+
def expire_peer_leases(self, stale_before: int) -> int:
|
|
249
|
+
return self.store.expire_secure_peer_leases(stale_before)
|
|
250
|
+
|
|
251
|
+
def revoke_peer(self, *, peer_id: str, team_id: str) -> None:
|
|
252
|
+
# The core peer credential is revoked before this projection call.
|
|
253
|
+
# Fence new adapter work and wait for already-authorized response
|
|
254
|
+
# streams so successful revocation proves no old peer still has bytes.
|
|
255
|
+
with self._rate_condition:
|
|
256
|
+
self._revoking.add(peer_id)
|
|
257
|
+
self.close_mail_hint_streams(peer_id)
|
|
258
|
+
# A bounded snapshot admitted before revocation must register/retire
|
|
259
|
+
# before projection revocation returns. New subscriptions are fenced.
|
|
260
|
+
with self._rate_condition:
|
|
261
|
+
while self._mail_leases.get(peer_id):
|
|
262
|
+
self._rate_condition.wait()
|
|
263
|
+
while True:
|
|
264
|
+
with self._rate_condition:
|
|
265
|
+
if self._in_flight.get(peer_id, 0) <= 0:
|
|
266
|
+
break
|
|
267
|
+
aborters = tuple(self._stream_aborters.get(peer_id, ()))
|
|
268
|
+
for abort in aborters:
|
|
269
|
+
with suppress(Exception):
|
|
270
|
+
abort()
|
|
271
|
+
with self._rate_condition:
|
|
272
|
+
if self._in_flight.get(peer_id, 0) > 0:
|
|
273
|
+
self._rate_condition.wait(timeout=0.25)
|
|
274
|
+
try:
|
|
275
|
+
self.store.revoke_secure_peer_service(peer_id=peer_id, team_id=team_id)
|
|
276
|
+
finally:
|
|
277
|
+
with self._rate_condition:
|
|
278
|
+
self._revoking.discard(peer_id)
|
|
279
|
+
self._rate_condition.notify_all()
|
|
280
|
+
|
|
281
|
+
def resource_team(self, resource_kind: str, resource_id: str) -> str | None:
|
|
282
|
+
return self.store.secure_peer_resource_team(resource_kind, resource_id)
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _json(status: int, value: dict[str, Any]) -> ProxyResponse:
|
|
286
|
+
return ProxyResponse(
|
|
287
|
+
status=status,
|
|
288
|
+
headers=(("content-type", "application/json"), ("cache-control", "no-store")),
|
|
289
|
+
body=canonical_json(value),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _claims(store: HubStore, peer: PeerAuthorization):
|
|
294
|
+
return store.secure_peer_claims(
|
|
295
|
+
peer_id=peer.peer_id,
|
|
296
|
+
peer_server_identity=peer.peer_server_identity,
|
|
297
|
+
team_id=peer.team_id,
|
|
298
|
+
scopes=peer.scopes,
|
|
299
|
+
expires_at=peer.certificate_expires_at,
|
|
300
|
+
display_name=peer.peer_display_name,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _message_body(request: ProxyRequest) -> dict[str, Any]:
|
|
305
|
+
try:
|
|
306
|
+
value = json.loads(request.body)
|
|
307
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
308
|
+
raise HubError("invalid_request", "Request body is invalid", 422) from exc
|
|
309
|
+
if not isinstance(value, dict):
|
|
310
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
311
|
+
allowed = {
|
|
312
|
+
"body",
|
|
313
|
+
"body_format",
|
|
314
|
+
"kind",
|
|
315
|
+
"thread_root_message_id",
|
|
316
|
+
"parent_message_id",
|
|
317
|
+
"idempotency_key",
|
|
318
|
+
}
|
|
319
|
+
if not set(value).issubset(allowed) or not {"body", "idempotency_key"}.issubset(value):
|
|
320
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
321
|
+
body = value.get("body")
|
|
322
|
+
key = value.get("idempotency_key")
|
|
323
|
+
body_format = value.get("body_format", "markdown")
|
|
324
|
+
kind = value.get("kind", "post")
|
|
325
|
+
if (
|
|
326
|
+
not isinstance(body, str)
|
|
327
|
+
or not 1 <= len(body.encode("utf-8", "strict")) <= 65_536
|
|
328
|
+
or not isinstance(key, str)
|
|
329
|
+
or key != key.strip()
|
|
330
|
+
or not 8 <= len(key.encode("utf-8", "strict")) <= 240
|
|
331
|
+
or body_format not in {"plain", "markdown"}
|
|
332
|
+
or kind not in {"post", "announcement"}
|
|
333
|
+
):
|
|
334
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
335
|
+
result = {
|
|
336
|
+
"body": body,
|
|
337
|
+
"body_format": body_format,
|
|
338
|
+
"kind": kind,
|
|
339
|
+
"thread_root_message_id": value.get("thread_root_message_id"),
|
|
340
|
+
"parent_message_id": value.get("parent_message_id"),
|
|
341
|
+
"idempotency_key": key,
|
|
342
|
+
}
|
|
343
|
+
for field in ("thread_root_message_id", "parent_message_id"):
|
|
344
|
+
item = result[field]
|
|
345
|
+
if item is not None and (
|
|
346
|
+
not isinstance(item, str)
|
|
347
|
+
or item != item.strip()
|
|
348
|
+
or not 1 <= len(item.encode("utf-8", "strict")) <= 240
|
|
349
|
+
):
|
|
350
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _object_body(
|
|
355
|
+
request: ProxyRequest,
|
|
356
|
+
*,
|
|
357
|
+
allowed: set[str],
|
|
358
|
+
required: set[str],
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
try:
|
|
361
|
+
value = json.loads(request.body)
|
|
362
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
363
|
+
raise HubError("invalid_request", "Request body is invalid", 422) from exc
|
|
364
|
+
if (
|
|
365
|
+
not isinstance(value, dict)
|
|
366
|
+
or not set(value).issubset(allowed)
|
|
367
|
+
or not required.issubset(value)
|
|
368
|
+
):
|
|
369
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
370
|
+
return value
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _identifier(
|
|
374
|
+
value: Any,
|
|
375
|
+
*,
|
|
376
|
+
minimum: int = 1,
|
|
377
|
+
maximum: int = 240,
|
|
378
|
+
) -> str:
|
|
379
|
+
try:
|
|
380
|
+
encoded = value.encode("utf-8", "strict") if isinstance(value, str) else b""
|
|
381
|
+
except UnicodeEncodeError as exc:
|
|
382
|
+
raise HubError("invalid_request", "Request body is invalid", 422) from exc
|
|
383
|
+
if (
|
|
384
|
+
not isinstance(value, str)
|
|
385
|
+
or value != value.strip()
|
|
386
|
+
or not minimum <= len(encoded) <= maximum
|
|
387
|
+
):
|
|
388
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
389
|
+
return value
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _resource_id(value: Any) -> str:
|
|
393
|
+
try:
|
|
394
|
+
return _identity(value) if isinstance(value, str) else _identity("")
|
|
395
|
+
except ValueError as exc:
|
|
396
|
+
raise HubError("invalid_request", "Resource identifier is invalid", 422) from exc
|
|
397
|
+
|
|
398
|
+
@classmethod
|
|
399
|
+
def _address(cls, value: Any) -> dict[str, str]:
|
|
400
|
+
if not isinstance(value, dict) or set(value) != {"kind", "id"}:
|
|
401
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
402
|
+
if value.get("kind") != "server":
|
|
403
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
404
|
+
return {
|
|
405
|
+
"kind": str(value["kind"]),
|
|
406
|
+
"id": cls._identifier(value.get("id")),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def _network_agent_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
411
|
+
value = cls._object_body(
|
|
412
|
+
request,
|
|
413
|
+
allowed={
|
|
414
|
+
"external_agent_id",
|
|
415
|
+
"backend",
|
|
416
|
+
"display_name",
|
|
417
|
+
"idempotency_key",
|
|
418
|
+
},
|
|
419
|
+
required={
|
|
420
|
+
"external_agent_id",
|
|
421
|
+
"backend",
|
|
422
|
+
"display_name",
|
|
423
|
+
"idempotency_key",
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
if value.get("backend") not in {"codex", "claude", "other"}:
|
|
427
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
428
|
+
return {
|
|
429
|
+
"external_agent_id": cls._identifier(value["external_agent_id"]),
|
|
430
|
+
"backend": value["backend"],
|
|
431
|
+
"display_name": cls._identifier(value["display_name"], maximum=160),
|
|
432
|
+
"idempotency_key": cls._identifier(
|
|
433
|
+
value["idempotency_key"], minimum=8
|
|
434
|
+
),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def _network_text_body(
|
|
439
|
+
cls,
|
|
440
|
+
request: ProxyRequest,
|
|
441
|
+
*,
|
|
442
|
+
mode: str,
|
|
443
|
+
) -> dict[str, Any]:
|
|
444
|
+
if mode == "bulletin":
|
|
445
|
+
allowed = {
|
|
446
|
+
"body",
|
|
447
|
+
"body_format",
|
|
448
|
+
"reply_to_post_id",
|
|
449
|
+
"idempotency_key",
|
|
450
|
+
}
|
|
451
|
+
required = {"body", "idempotency_key"}
|
|
452
|
+
elif mode == "mailbox":
|
|
453
|
+
allowed = {
|
|
454
|
+
"to",
|
|
455
|
+
"from_agent_id",
|
|
456
|
+
"body",
|
|
457
|
+
"body_format",
|
|
458
|
+
"idempotency_key",
|
|
459
|
+
}
|
|
460
|
+
required = {"to", "body", "idempotency_key"}
|
|
461
|
+
elif mode == "request":
|
|
462
|
+
allowed = {
|
|
463
|
+
"to",
|
|
464
|
+
"from_agent_id",
|
|
465
|
+
"body",
|
|
466
|
+
"body_format",
|
|
467
|
+
"expires_in_seconds",
|
|
468
|
+
"idempotency_key",
|
|
469
|
+
}
|
|
470
|
+
required = {"to", "body", "idempotency_key"}
|
|
471
|
+
elif mode == "reply":
|
|
472
|
+
allowed = {
|
|
473
|
+
"from_agent_id",
|
|
474
|
+
"body",
|
|
475
|
+
"body_format",
|
|
476
|
+
"idempotency_key",
|
|
477
|
+
}
|
|
478
|
+
required = {"body", "idempotency_key"}
|
|
479
|
+
else: # pragma: no cover - internal contract only.
|
|
480
|
+
raise RuntimeError("unknown Team Networks body mode")
|
|
481
|
+
value = cls._object_body(
|
|
482
|
+
request, allowed=allowed, required=required
|
|
483
|
+
)
|
|
484
|
+
body = value.get("body")
|
|
485
|
+
try:
|
|
486
|
+
body_bytes = body.encode("utf-8", "strict") if isinstance(body, str) else b""
|
|
487
|
+
except UnicodeEncodeError as exc:
|
|
488
|
+
raise HubError("invalid_request", "Request body is invalid", 422) from exc
|
|
489
|
+
body_format = value.get("body_format", "markdown")
|
|
490
|
+
if not 1 <= len(body_bytes) <= MAX_NETWORK_BODY_BYTES or body_format not in {"plain", "markdown"}:
|
|
491
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
492
|
+
result: dict[str, Any] = {
|
|
493
|
+
"body": body,
|
|
494
|
+
"body_format": body_format,
|
|
495
|
+
"idempotency_key": cls._identifier(
|
|
496
|
+
value["idempotency_key"], minimum=8
|
|
497
|
+
),
|
|
498
|
+
}
|
|
499
|
+
from_agent = value.get("from_agent_id")
|
|
500
|
+
if mode in {"mailbox", "request", "reply"} and from_agent is not None:
|
|
501
|
+
# A secure peer speaks for its authenticated server identity. It
|
|
502
|
+
# may not forge an agent sender, even when replaying a legacy body.
|
|
503
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
504
|
+
result["from_agent_id"] = None
|
|
505
|
+
if mode in {"mailbox", "request"}:
|
|
506
|
+
result["to"] = cls._address(value.get("to"))
|
|
507
|
+
if mode == "bulletin":
|
|
508
|
+
reply_to = value.get("reply_to_post_id")
|
|
509
|
+
result["reply_to_post_id"] = (
|
|
510
|
+
cls._identifier(reply_to) if reply_to is not None else None
|
|
511
|
+
)
|
|
512
|
+
result.pop("from_agent_id", None)
|
|
513
|
+
if mode == "request":
|
|
514
|
+
ttl = value.get("expires_in_seconds", 86_400)
|
|
515
|
+
if type(ttl) is not int or not 60 <= ttl <= 86_400:
|
|
516
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
517
|
+
result["expires_in_seconds"] = ttl
|
|
518
|
+
return result
|
|
519
|
+
|
|
520
|
+
@classmethod
|
|
521
|
+
def _network_receipt_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
522
|
+
value = cls._object_body(
|
|
523
|
+
request,
|
|
524
|
+
allowed={"state", "idempotency_key"},
|
|
525
|
+
required={"state", "idempotency_key"},
|
|
526
|
+
)
|
|
527
|
+
if value.get("state") not in {"delivered", "read"}:
|
|
528
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
529
|
+
return {
|
|
530
|
+
"state": value["state"],
|
|
531
|
+
"idempotency_key": cls._identifier(
|
|
532
|
+
value["idempotency_key"], minimum=8
|
|
533
|
+
),
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def _team_message_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
538
|
+
"""Shape-check a Team Messages V2 create request; the store validates values."""
|
|
539
|
+
|
|
540
|
+
value = cls._object_body(
|
|
541
|
+
request,
|
|
542
|
+
allowed={
|
|
543
|
+
"kind",
|
|
544
|
+
"title",
|
|
545
|
+
"body",
|
|
546
|
+
"body_format",
|
|
547
|
+
"recipients",
|
|
548
|
+
"attachment_ids",
|
|
549
|
+
"in_reply_to_message_id",
|
|
550
|
+
"skill",
|
|
551
|
+
"provenance",
|
|
552
|
+
"idempotency_key",
|
|
553
|
+
},
|
|
554
|
+
required={"kind", "body", "recipients", "idempotency_key"},
|
|
555
|
+
)
|
|
556
|
+
recipients = value.get("recipients")
|
|
557
|
+
if (
|
|
558
|
+
not isinstance(recipients, list)
|
|
559
|
+
or not 1 <= len(recipients) <= 16
|
|
560
|
+
or any(
|
|
561
|
+
not isinstance(item, dict) or not set(item).issubset({
|
|
562
|
+
"kind", "id", "mail_route_lifecycle_id",
|
|
563
|
+
})
|
|
564
|
+
for item in recipients
|
|
565
|
+
)
|
|
566
|
+
):
|
|
567
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
568
|
+
# A durable @@ grant pins the recipient's inbox incarnation. Preserve
|
|
569
|
+
# that precondition across mTLS just like the direct Hub API; the store
|
|
570
|
+
# validates its value and checks it inside the message transaction.
|
|
571
|
+
skill = value.get("skill")
|
|
572
|
+
if skill is not None and (
|
|
573
|
+
not isinstance(skill, dict)
|
|
574
|
+
or not set(skill).issubset(
|
|
575
|
+
{"slug", "summary", "tags", "change_note", "expected_version"}
|
|
576
|
+
)
|
|
577
|
+
):
|
|
578
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
579
|
+
value["idempotency_key"] = cls._identifier(value["idempotency_key"], minimum=8)
|
|
580
|
+
return value
|
|
581
|
+
|
|
582
|
+
@classmethod
|
|
583
|
+
def _team_receipt_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
584
|
+
value = cls._object_body(
|
|
585
|
+
request,
|
|
586
|
+
allowed={"state", "idempotency_key", "address_kind", "address_id"},
|
|
587
|
+
required={"state", "idempotency_key"},
|
|
588
|
+
)
|
|
589
|
+
if value.get("state") not in {"delivered", "read"}:
|
|
590
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
591
|
+
if ("address_kind" in value) != ("address_id" in value):
|
|
592
|
+
raise HubError("invalid_request", "Receipt mailbox requires kind and id", 422)
|
|
593
|
+
if "address_kind" in value and value["address_kind"] not in {"human", "server"}:
|
|
594
|
+
raise HubError("invalid_request", "Receipt mailbox is invalid", 422)
|
|
595
|
+
return {
|
|
596
|
+
"state": value["state"],
|
|
597
|
+
"idempotency_key": cls._identifier(value["idempotency_key"], minimum=8),
|
|
598
|
+
**({"address_kind": value["address_kind"], "address_id": cls._identifier(value["address_id"], minimum=8)}
|
|
599
|
+
if "address_kind" in value else {}),
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
@classmethod
|
|
603
|
+
def _team_dismissal_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
604
|
+
value = cls._object_body(request,
|
|
605
|
+
allowed={"address_kind", "address_id", "idempotency_key"},
|
|
606
|
+
required={"address_kind", "address_id", "idempotency_key"})
|
|
607
|
+
if value["address_kind"] not in {"human", "server"}:
|
|
608
|
+
raise HubError("invalid_request", "Mailbox is invalid", 422)
|
|
609
|
+
return {"address_kind": value["address_kind"],
|
|
610
|
+
"address_id": cls._identifier(value["address_id"], minimum=8),
|
|
611
|
+
"idempotency_key": cls._identifier(value["idempotency_key"], minimum=8)}
|
|
612
|
+
|
|
613
|
+
@classmethod
|
|
614
|
+
def _team_mailbox_state_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
615
|
+
fields = {"address_kind", "address_id", "unread", "expected_version", "idempotency_key"}
|
|
616
|
+
value = cls._object_body(request, allowed=fields, required=fields)
|
|
617
|
+
if value["address_kind"] != "server" or type(value["unread"]) is not bool \
|
|
618
|
+
or type(value["expected_version"]) is not int or not 0 <= value["expected_version"] < 9_007_199_254_740_991:
|
|
619
|
+
raise HubError("invalid_request", "Server mailbox state is invalid", 422)
|
|
620
|
+
return {**value, "address_id": cls._identifier(value["address_id"], minimum=8),
|
|
621
|
+
"idempotency_key": cls._identifier(value["idempotency_key"], minimum=8)}
|
|
622
|
+
|
|
623
|
+
@classmethod
|
|
624
|
+
def _team_message_revision_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
625
|
+
value = cls._object_body(
|
|
626
|
+
request,
|
|
627
|
+
allowed={"body", "body_format", "expected_version", "idempotency_key"},
|
|
628
|
+
required={"body", "expected_version", "idempotency_key"},
|
|
629
|
+
)
|
|
630
|
+
value["idempotency_key"] = cls._identifier(
|
|
631
|
+
value["idempotency_key"], minimum=8
|
|
632
|
+
)
|
|
633
|
+
return value
|
|
634
|
+
|
|
635
|
+
@classmethod
|
|
636
|
+
def _network_delete_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
637
|
+
value = cls._object_body(
|
|
638
|
+
request,
|
|
639
|
+
allowed={"idempotency_key"},
|
|
640
|
+
required={"idempotency_key"},
|
|
641
|
+
)
|
|
642
|
+
return {
|
|
643
|
+
"idempotency_key": cls._identifier(
|
|
644
|
+
value["idempotency_key"],
|
|
645
|
+
minimum=8,
|
|
646
|
+
),
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
@classmethod
|
|
650
|
+
def _team_attachment_body(cls, request: ProxyRequest) -> dict[str, Any]:
|
|
651
|
+
value = cls._object_body(
|
|
652
|
+
request,
|
|
653
|
+
allowed={"file_name", "media_type", "byte_size", "sha256", "idempotency_key"},
|
|
654
|
+
required={"file_name", "media_type", "byte_size", "sha256", "idempotency_key"},
|
|
655
|
+
)
|
|
656
|
+
value["idempotency_key"] = cls._identifier(value["idempotency_key"], minimum=8)
|
|
657
|
+
return value
|
|
658
|
+
|
|
659
|
+
@classmethod
|
|
660
|
+
def _team_skill_flag_body(cls, request: ProxyRequest, flag: str) -> dict[str, Any]:
|
|
661
|
+
value = cls._object_body(
|
|
662
|
+
request,
|
|
663
|
+
allowed={flag, "idempotency_key"},
|
|
664
|
+
required={flag, "idempotency_key"},
|
|
665
|
+
)
|
|
666
|
+
if type(value.get(flag)) is not bool:
|
|
667
|
+
raise HubError("invalid_request", "Request body is invalid", 422)
|
|
668
|
+
return {
|
|
669
|
+
flag: value[flag],
|
|
670
|
+
"idempotency_key": cls._identifier(value["idempotency_key"], minimum=8),
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _team_query(request: ProxyRequest, *, allowed: set[str]) -> dict[str, str]:
|
|
675
|
+
"""Bound Team Messages V2 query strings without mailbox-specific rules."""
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
pairs = parse_qsl(request.query, keep_blank_values=True, strict_parsing=True)
|
|
679
|
+
except ValueError as exc:
|
|
680
|
+
raise HubError("invalid_request", "Query is invalid", 422) from exc
|
|
681
|
+
if len(pairs) != len({key for key, _value in pairs}) or any(
|
|
682
|
+
key not in allowed for key, _value in pairs
|
|
683
|
+
):
|
|
684
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
685
|
+
values = dict(pairs)
|
|
686
|
+
for key in ("after_sequence", "limit", "version"):
|
|
687
|
+
if key in values and (
|
|
688
|
+
not values[key].isdigit() or str(int(values[key])) != values[key]
|
|
689
|
+
):
|
|
690
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
691
|
+
if "q" in values:
|
|
692
|
+
HubStore._team_message_search_expression(values["q"])
|
|
693
|
+
if "limit" in values and not 1 <= int(values["limit"]) <= 100:
|
|
694
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
695
|
+
if "version" in values and not 1 <= int(values["version"]) <= 200:
|
|
696
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
697
|
+
for flag in ("include_mail_subject", "include_mailbox_state", "include_mailbox_coverage"):
|
|
698
|
+
if flag in values and values[flag] not in {"0", "1", "true", "false"}:
|
|
699
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
700
|
+
if "after_arrival_id" in values and re.fullmatch(r"tmsg_[0-9a-f]{32}", values["after_arrival_id"]) is None:
|
|
701
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
702
|
+
if "cursor" in values:
|
|
703
|
+
if re.fullmatch(r"v1\.[A-Za-z0-9_-]{38,500}", values["cursor"]) is None:
|
|
704
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
705
|
+
for key in ("address_id", "from_id"):
|
|
706
|
+
if key in values:
|
|
707
|
+
values[key] = SecurePeerHubAdapter._resource_id(values[key])
|
|
708
|
+
return values
|
|
709
|
+
|
|
710
|
+
@staticmethod
|
|
711
|
+
def _query_flag(values: dict[str, str], key: str) -> bool:
|
|
712
|
+
return values.get(key, "0") in {"1", "true"}
|
|
713
|
+
|
|
714
|
+
@staticmethod
|
|
715
|
+
def _query(
|
|
716
|
+
request: ProxyRequest,
|
|
717
|
+
*,
|
|
718
|
+
allowed: set[str],
|
|
719
|
+
) -> dict[str, str]:
|
|
720
|
+
try:
|
|
721
|
+
pairs = parse_qsl(request.query, keep_blank_values=True, strict_parsing=True)
|
|
722
|
+
except ValueError as exc:
|
|
723
|
+
raise HubError("invalid_request", "Query is invalid", 422) from exc
|
|
724
|
+
if len(pairs) != len({key for key, _value in pairs}) or any(
|
|
725
|
+
key not in allowed for key, _value in pairs
|
|
726
|
+
):
|
|
727
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
728
|
+
values = dict(pairs)
|
|
729
|
+
for key in ("after_sequence", "limit"):
|
|
730
|
+
if key in values and (
|
|
731
|
+
not values[key].isdigit()
|
|
732
|
+
or str(int(values[key])) != values[key]
|
|
733
|
+
):
|
|
734
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
735
|
+
if "after_sequence" in values and not 0 <= int(
|
|
736
|
+
values["after_sequence"]
|
|
737
|
+
) <= 9_223_372_036_854_775_807:
|
|
738
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
739
|
+
if "limit" in values and not 1 <= int(values["limit"]) <= 100:
|
|
740
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
741
|
+
if "address_kind" in values and values["address_kind"] != "server":
|
|
742
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
743
|
+
if "address_id" in values:
|
|
744
|
+
values["address_id"] = SecurePeerHubAdapter._resource_id(
|
|
745
|
+
values["address_id"]
|
|
746
|
+
)
|
|
747
|
+
if "after_server_id" in values:
|
|
748
|
+
values["after_server_id"] = SecurePeerHubAdapter._resource_id(
|
|
749
|
+
values["after_server_id"]
|
|
750
|
+
)
|
|
751
|
+
return values
|
|
752
|
+
|
|
753
|
+
@staticmethod
|
|
754
|
+
def _attachment_error(exc: HubError) -> AttachmentProxyResponse:
|
|
755
|
+
body = canonical_json({"error": {"code": exc.code, "message": exc.message}})
|
|
756
|
+
return AttachmentProxyResponse(
|
|
757
|
+
status=exc.status_code,
|
|
758
|
+
headers=(
|
|
759
|
+
("content-type", "application/json"),
|
|
760
|
+
("cache-control", "no-store"),
|
|
761
|
+
),
|
|
762
|
+
body=body,
|
|
763
|
+
length=len(body),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
def forward_attachment(
|
|
767
|
+
self, request: AttachmentProxyRequest
|
|
768
|
+
) -> AttachmentProxyResponse:
|
|
769
|
+
"""Serve the attachment-only binary lane after gateway authorization."""
|
|
770
|
+
|
|
771
|
+
admitted = False
|
|
772
|
+
deferred_release = False
|
|
773
|
+
attachment_source: AttachmentFileLease | None = None
|
|
774
|
+
attachment_source_transferred = False
|
|
775
|
+
try:
|
|
776
|
+
self._admit(
|
|
777
|
+
request.peer.peer_id,
|
|
778
|
+
write=request.method == "PUT",
|
|
779
|
+
attachment=True,
|
|
780
|
+
)
|
|
781
|
+
admitted = True
|
|
782
|
+
claims = self._claims(self.store, request.peer)
|
|
783
|
+
pieces = request.path[len(_TEAM_PREFIX) :].split("/")
|
|
784
|
+
if (
|
|
785
|
+
len(pieces) != 5
|
|
786
|
+
or pieces[1:3] != [_NETWORK_CHILD, "attachments"]
|
|
787
|
+
or pieces[4] != "content"
|
|
788
|
+
):
|
|
789
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
790
|
+
team_id = pieces[0]
|
|
791
|
+
attachment_id = self._resource_id(pieces[3])
|
|
792
|
+
headers = dict(request.headers)
|
|
793
|
+
if request.method == "PUT":
|
|
794
|
+
match = _CONTENT_RANGE_RE.fullmatch(headers.get("content-range", ""))
|
|
795
|
+
if match is None:
|
|
796
|
+
raise HubError("invalid_request", "Content-Range is invalid", 422)
|
|
797
|
+
start = int(match.group("start"))
|
|
798
|
+
end = int(match.group("end"))
|
|
799
|
+
total = int(match.group("total"))
|
|
800
|
+
if end < start or end - start + 1 != len(request.body) or end >= total:
|
|
801
|
+
raise HubError("invalid_request", "Content-Range is invalid", 422)
|
|
802
|
+
result = self.store.write_team_attachment_chunk(
|
|
803
|
+
claims,
|
|
804
|
+
team_id,
|
|
805
|
+
attachment_id,
|
|
806
|
+
offset=start,
|
|
807
|
+
total=total,
|
|
808
|
+
data=request.body,
|
|
809
|
+
)
|
|
810
|
+
body = canonical_json(result)
|
|
811
|
+
return AttachmentProxyResponse(
|
|
812
|
+
status=200,
|
|
813
|
+
headers=(
|
|
814
|
+
("content-type", "application/json"),
|
|
815
|
+
("cache-control", "no-store"),
|
|
816
|
+
),
|
|
817
|
+
body=body,
|
|
818
|
+
length=len(body),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
attachment, source = self.store.open_team_attachment(
|
|
822
|
+
claims, team_id, attachment_id
|
|
823
|
+
)
|
|
824
|
+
attachment_source = source
|
|
825
|
+
size = int(attachment["byte_size"])
|
|
826
|
+
response_headers: list[tuple[str, str]] = [
|
|
827
|
+
("accept-ranges", "bytes"),
|
|
828
|
+
(
|
|
829
|
+
"content-disposition",
|
|
830
|
+
"inline; filename*=UTF-8''"
|
|
831
|
+
+ quote(str(attachment["file_name"]), safe=""),
|
|
832
|
+
),
|
|
833
|
+
("cache-control", "private, max-age=0"),
|
|
834
|
+
("x-content-type-options", "nosniff"),
|
|
835
|
+
("etag", f'"{attachment["sha256"]}"'),
|
|
836
|
+
("content-type", str(attachment["media_type"])),
|
|
837
|
+
]
|
|
838
|
+
start, end, status = 0, size - 1, 200
|
|
839
|
+
range_value = headers.get("range")
|
|
840
|
+
if range_value:
|
|
841
|
+
match = _RANGE_RE.fullmatch(range_value)
|
|
842
|
+
invalid = match is None
|
|
843
|
+
if not invalid and match is not None:
|
|
844
|
+
raw_start, raw_end = match.group("start"), match.group("end")
|
|
845
|
+
if raw_start == "" and raw_end == "":
|
|
846
|
+
invalid = True
|
|
847
|
+
elif raw_start == "":
|
|
848
|
+
suffix = int(raw_end)
|
|
849
|
+
if suffix == 0:
|
|
850
|
+
invalid = True
|
|
851
|
+
else:
|
|
852
|
+
start, end = max(0, size - suffix), size - 1
|
|
853
|
+
else:
|
|
854
|
+
start = int(raw_start)
|
|
855
|
+
end = int(raw_end) if raw_end else size - 1
|
|
856
|
+
if start >= size or end < start:
|
|
857
|
+
invalid = True
|
|
858
|
+
end = min(end, size - 1)
|
|
859
|
+
if invalid:
|
|
860
|
+
source.close()
|
|
861
|
+
response_headers.append(("content-range", f"bytes */{size}"))
|
|
862
|
+
return AttachmentProxyResponse(
|
|
863
|
+
status=416,
|
|
864
|
+
headers=tuple(response_headers),
|
|
865
|
+
)
|
|
866
|
+
status = 206
|
|
867
|
+
response_headers.append(
|
|
868
|
+
("content-range", f"bytes {start}-{end}/{size}")
|
|
869
|
+
)
|
|
870
|
+
descriptor = source.descriptor
|
|
871
|
+
try:
|
|
872
|
+
info = os.fstat(descriptor)
|
|
873
|
+
except OSError as exc:
|
|
874
|
+
source.close()
|
|
875
|
+
raise HubError(
|
|
876
|
+
"attachment_unavailable", "Attachment content is unavailable", 409
|
|
877
|
+
) from exc
|
|
878
|
+
if (
|
|
879
|
+
not stat.S_ISREG(info.st_mode)
|
|
880
|
+
or info.st_uid != os.getuid()
|
|
881
|
+
or info.st_size != size
|
|
882
|
+
):
|
|
883
|
+
source.close()
|
|
884
|
+
raise HubError(
|
|
885
|
+
"attachment_unavailable", "Attachment content is unavailable", 409
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
stream_lock = threading.RLock()
|
|
889
|
+
finalized = False
|
|
890
|
+
cancelled = threading.Event()
|
|
891
|
+
|
|
892
|
+
def abort() -> None:
|
|
893
|
+
# Never close a descriptor from the revocation thread while
|
|
894
|
+
# the gateway may be between pread calls. Descriptor numbers
|
|
895
|
+
# can be reused process-wide. The gateway observes this flag,
|
|
896
|
+
# owns descriptor closure, and releases the stream lease.
|
|
897
|
+
cancelled.set()
|
|
898
|
+
|
|
899
|
+
def finalize() -> None:
|
|
900
|
+
nonlocal finalized
|
|
901
|
+
with stream_lock:
|
|
902
|
+
if finalized:
|
|
903
|
+
return
|
|
904
|
+
finalized = True
|
|
905
|
+
source.close()
|
|
906
|
+
with self._rate_condition:
|
|
907
|
+
aborters = self._stream_aborters.get(request.peer.peer_id)
|
|
908
|
+
if aborters is not None:
|
|
909
|
+
aborters.discard(abort)
|
|
910
|
+
if not aborters:
|
|
911
|
+
self._stream_aborters.pop(request.peer.peer_id, None)
|
|
912
|
+
self._release(request.peer.peer_id)
|
|
913
|
+
|
|
914
|
+
with self._rate_condition:
|
|
915
|
+
if request.peer.peer_id in self._revoking:
|
|
916
|
+
# The descriptor has not escaped to the gateway yet, so
|
|
917
|
+
# this thread still owns it and can close it synchronously.
|
|
918
|
+
source.close()
|
|
919
|
+
raise HubError(
|
|
920
|
+
"forbidden", "Secure peer authorization is being revoked", 403
|
|
921
|
+
)
|
|
922
|
+
self._stream_aborters.setdefault(request.peer.peer_id, set()).add(
|
|
923
|
+
abort
|
|
924
|
+
)
|
|
925
|
+
response = AttachmentProxyResponse(
|
|
926
|
+
status=status,
|
|
927
|
+
headers=tuple(response_headers),
|
|
928
|
+
descriptor=descriptor,
|
|
929
|
+
offset=start,
|
|
930
|
+
length=end - start + 1,
|
|
931
|
+
finalizer=finalize,
|
|
932
|
+
cancelled=cancelled.is_set,
|
|
933
|
+
)
|
|
934
|
+
deferred_release = True
|
|
935
|
+
attachment_source_transferred = True
|
|
936
|
+
return response
|
|
937
|
+
except HubError as exc:
|
|
938
|
+
return self._attachment_error(exc)
|
|
939
|
+
finally:
|
|
940
|
+
if attachment_source is not None and not attachment_source_transferred:
|
|
941
|
+
attachment_source.close()
|
|
942
|
+
if admitted and not deferred_release:
|
|
943
|
+
self._release(request.peer.peer_id)
|
|
944
|
+
|
|
945
|
+
def forward(self, request: ProxyRequest) -> ProxyResponse:
|
|
946
|
+
"""Serve one already-sanitized request without network recursion."""
|
|
947
|
+
|
|
948
|
+
admitted = False
|
|
949
|
+
try:
|
|
950
|
+
self._admit(
|
|
951
|
+
request.peer.peer_id,
|
|
952
|
+
write=request.method not in {"GET", "HEAD"},
|
|
953
|
+
)
|
|
954
|
+
admitted = True
|
|
955
|
+
claims = self._claims(self.store, request.peer)
|
|
956
|
+
path = request.path
|
|
957
|
+
if request.method == "GET" and path == "/v1/health":
|
|
958
|
+
result = {**self.store.health(), "peer_session_available": True}
|
|
959
|
+
elif request.method == "GET" and path in {"/v1/peer-session", "/v1/session"}:
|
|
960
|
+
result = self.store.session_snapshot(claims)
|
|
961
|
+
elif request.method == "GET" and path == "/v1/teams":
|
|
962
|
+
result = self.store.list_teams(claims)
|
|
963
|
+
elif request.method == "GET" and path.startswith(_TEAM_PREFIX):
|
|
964
|
+
remainder = path[len(_TEAM_PREFIX) :]
|
|
965
|
+
pieces = remainder.split("/")
|
|
966
|
+
team_id = pieces[0]
|
|
967
|
+
if len(pieces) == 1:
|
|
968
|
+
result = self.store.get_team(claims, team_id)
|
|
969
|
+
elif len(pieces) == 2 and pieces[1] == "members":
|
|
970
|
+
values = self._query(request, allowed={"limit", "cursor"})
|
|
971
|
+
result = self.store.list_members(
|
|
972
|
+
claims,
|
|
973
|
+
team_id,
|
|
974
|
+
limit=int(values.get("limit", "50")),
|
|
975
|
+
cursor=values.get("cursor"),
|
|
976
|
+
)
|
|
977
|
+
elif len(pieces) == 3 and pieces[1] == "members":
|
|
978
|
+
self._query(request, allowed=set())
|
|
979
|
+
result = self.store.get_member(claims, team_id, pieces[2])
|
|
980
|
+
elif len(pieces) == 2 and pieces[1] == "nodes":
|
|
981
|
+
result = self.store.list_nodes(claims, team_id)
|
|
982
|
+
elif len(pieces) == 2 and pieces[1] == "channels":
|
|
983
|
+
result = self.store.list_channels(claims, team_id)
|
|
984
|
+
elif len(pieces) == 2 and pieces[1] == _NETWORK_CHILD:
|
|
985
|
+
values = self._query(
|
|
986
|
+
request, allowed={"after_server_id", "limit"}
|
|
987
|
+
)
|
|
988
|
+
result = self.store.get_network(
|
|
989
|
+
claims,
|
|
990
|
+
team_id,
|
|
991
|
+
after_server_id=values.get("after_server_id"),
|
|
992
|
+
limit=int(values.get("limit", "50")),
|
|
993
|
+
)
|
|
994
|
+
elif (
|
|
995
|
+
len(pieces) == 4
|
|
996
|
+
and pieces[1:3] == [_NETWORK_CHILD, "servers"]
|
|
997
|
+
):
|
|
998
|
+
self._query(request, allowed=set())
|
|
999
|
+
result = self.store.get_network_server(
|
|
1000
|
+
claims,
|
|
1001
|
+
team_id,
|
|
1002
|
+
self._resource_id(pieces[3]),
|
|
1003
|
+
)
|
|
1004
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "bulletin"]:
|
|
1005
|
+
values = self._query(
|
|
1006
|
+
request, allowed={"after_sequence", "limit"}
|
|
1007
|
+
)
|
|
1008
|
+
result = self.store.list_network_bulletin(
|
|
1009
|
+
claims,
|
|
1010
|
+
team_id,
|
|
1011
|
+
after_sequence=int(values.get("after_sequence", "0")),
|
|
1012
|
+
limit=int(values.get("limit", "50")),
|
|
1013
|
+
)
|
|
1014
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "deletions"]:
|
|
1015
|
+
values = self._team_query(
|
|
1016
|
+
request,
|
|
1017
|
+
allowed={"after_sequence", "limit"},
|
|
1018
|
+
)
|
|
1019
|
+
result = self.store.list_network_content_deletions(
|
|
1020
|
+
claims,
|
|
1021
|
+
team_id,
|
|
1022
|
+
after_sequence=int(values.get("after_sequence", "0")),
|
|
1023
|
+
limit=int(values.get("limit", "50")),
|
|
1024
|
+
)
|
|
1025
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "mailbox"]:
|
|
1026
|
+
values = self._query(
|
|
1027
|
+
request,
|
|
1028
|
+
allowed={
|
|
1029
|
+
"address_kind",
|
|
1030
|
+
"address_id",
|
|
1031
|
+
"after_sequence",
|
|
1032
|
+
"limit",
|
|
1033
|
+
"include_revision",
|
|
1034
|
+
},
|
|
1035
|
+
)
|
|
1036
|
+
if not {"address_kind", "address_id"}.issubset(values):
|
|
1037
|
+
raise HubError("invalid_request", "Query is invalid", 422)
|
|
1038
|
+
result = self.store.list_network_mailbox(
|
|
1039
|
+
claims,
|
|
1040
|
+
team_id,
|
|
1041
|
+
address_kind=values["address_kind"],
|
|
1042
|
+
address_id=values["address_id"],
|
|
1043
|
+
after_sequence=int(values.get("after_sequence", "0")),
|
|
1044
|
+
limit=int(values.get("limit", "50")),
|
|
1045
|
+
)
|
|
1046
|
+
elif (
|
|
1047
|
+
len(pieces) == 4
|
|
1048
|
+
and pieces[1:3] == [_NETWORK_CHILD, "items"]
|
|
1049
|
+
):
|
|
1050
|
+
result = self.store.get_network_item(
|
|
1051
|
+
claims, team_id, self._resource_id(pieces[3])
|
|
1052
|
+
)
|
|
1053
|
+
elif (
|
|
1054
|
+
len(pieces) == 4
|
|
1055
|
+
and pieces[1:3] == [_NETWORK_CHILD, "requests"]
|
|
1056
|
+
):
|
|
1057
|
+
result = self.store.get_network_request(
|
|
1058
|
+
claims, team_id, self._resource_id(pieces[3])
|
|
1059
|
+
)
|
|
1060
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "messages"]:
|
|
1061
|
+
values = self._team_query(
|
|
1062
|
+
request,
|
|
1063
|
+
allowed={
|
|
1064
|
+
"box",
|
|
1065
|
+
"address_kind",
|
|
1066
|
+
"address_id",
|
|
1067
|
+
"unread",
|
|
1068
|
+
"from_kind",
|
|
1069
|
+
"from_id",
|
|
1070
|
+
"since",
|
|
1071
|
+
"after_sequence",
|
|
1072
|
+
"limit",
|
|
1073
|
+
"include_revision",
|
|
1074
|
+
"include_mail_subject",
|
|
1075
|
+
"include_mailbox_state",
|
|
1076
|
+
"include_mailbox_coverage", "after_arrival_id",
|
|
1077
|
+
"q",
|
|
1078
|
+
},
|
|
1079
|
+
)
|
|
1080
|
+
result = self.store.list_team_messages(
|
|
1081
|
+
claims,
|
|
1082
|
+
team_id,
|
|
1083
|
+
box=values.get("box", "inbox"),
|
|
1084
|
+
address_kind=values.get("address_kind"),
|
|
1085
|
+
address_id=values.get("address_id"),
|
|
1086
|
+
unread=self._query_flag(values, "unread"),
|
|
1087
|
+
from_kind=values.get("from_kind"),
|
|
1088
|
+
from_id=values.get("from_id"),
|
|
1089
|
+
since=values.get("since"),
|
|
1090
|
+
after_sequence=int(values.get("after_sequence", "0")),
|
|
1091
|
+
limit=int(values.get("limit", "50")),
|
|
1092
|
+
include_revision=self._query_flag(values, "include_revision"),
|
|
1093
|
+
include_mail_subject=self._query_flag(values, "include_mail_subject"),
|
|
1094
|
+
include_mailbox_state=self._query_flag(values, "include_mailbox_state"),
|
|
1095
|
+
include_mailbox_coverage=self._query_flag(values, "include_mailbox_coverage"),
|
|
1096
|
+
after_arrival_id=values.get("after_arrival_id"),
|
|
1097
|
+
q=values.get("q"),
|
|
1098
|
+
)
|
|
1099
|
+
elif len(pieces) == 4 and pieces[1:3] == [_NETWORK_CHILD, "messages"]:
|
|
1100
|
+
values = self._team_query(request, allowed={"include_revision", "include_mail_subject", "include_mailbox_state"})
|
|
1101
|
+
result = self.store.get_team_message(
|
|
1102
|
+
claims,
|
|
1103
|
+
team_id,
|
|
1104
|
+
self._resource_id(pieces[3]),
|
|
1105
|
+
include_revision=self._query_flag(values, "include_revision"),
|
|
1106
|
+
include_mail_subject=self._query_flag(values, "include_mail_subject"),
|
|
1107
|
+
include_mailbox_state=self._query_flag(values, "include_mailbox_state"),
|
|
1108
|
+
)
|
|
1109
|
+
elif (
|
|
1110
|
+
len(pieces) == 5
|
|
1111
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1112
|
+
and pieces[4] == "revisions"
|
|
1113
|
+
):
|
|
1114
|
+
values = self._team_query(request, allowed={"version"})
|
|
1115
|
+
result = self.store.list_team_message_revisions(
|
|
1116
|
+
claims, team_id, self._resource_id(pieces[3]),
|
|
1117
|
+
version=int(values["version"]) if "version" in values else None,
|
|
1118
|
+
)
|
|
1119
|
+
elif (
|
|
1120
|
+
len(pieces) == 5
|
|
1121
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1122
|
+
and pieces[4] == "thread"
|
|
1123
|
+
):
|
|
1124
|
+
values = self._team_query(request, allowed={"after_sequence", "limit"})
|
|
1125
|
+
result = self.store.get_team_message_thread(
|
|
1126
|
+
claims, team_id, self._resource_id(pieces[3]),
|
|
1127
|
+
after_sequence=int(values.get("after_sequence", "0")),
|
|
1128
|
+
limit=int(values.get("limit", "25")),
|
|
1129
|
+
)
|
|
1130
|
+
elif len(pieces) == 4 and pieces[1:3] == [_NETWORK_CHILD, "attachments"]:
|
|
1131
|
+
result = self.store.get_team_attachment(
|
|
1132
|
+
claims, team_id, self._resource_id(pieces[3])
|
|
1133
|
+
)
|
|
1134
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "skills"]:
|
|
1135
|
+
values = self._team_query(request, allowed={"include_archived", "slug"})
|
|
1136
|
+
result = self.store.list_team_skills(
|
|
1137
|
+
claims,
|
|
1138
|
+
team_id,
|
|
1139
|
+
include_archived=self._query_flag(values, "include_archived"),
|
|
1140
|
+
slug=values.get("slug"),
|
|
1141
|
+
)
|
|
1142
|
+
elif len(pieces) == 4 and pieces[1:3] == [_NETWORK_CHILD, "skills"]:
|
|
1143
|
+
result = self.store.get_team_skill(
|
|
1144
|
+
claims, team_id, self._resource_id(pieces[3])
|
|
1145
|
+
)
|
|
1146
|
+
elif (
|
|
1147
|
+
len(pieces) == 5
|
|
1148
|
+
and pieces[1:3] == [_NETWORK_CHILD, "skills"]
|
|
1149
|
+
and pieces[4] == "versions"
|
|
1150
|
+
):
|
|
1151
|
+
result = self.store.list_team_skill_versions(
|
|
1152
|
+
claims, team_id, self._resource_id(pieces[3])
|
|
1153
|
+
)
|
|
1154
|
+
elif (
|
|
1155
|
+
len(pieces) == 6
|
|
1156
|
+
and pieces[1:3] == [_NETWORK_CHILD, "skills"]
|
|
1157
|
+
and pieces[4] == "versions"
|
|
1158
|
+
and pieces[5].isdigit()
|
|
1159
|
+
):
|
|
1160
|
+
result = self.store.get_team_skill_version(
|
|
1161
|
+
claims, team_id, self._resource_id(pieces[3]), int(pieces[5])
|
|
1162
|
+
)
|
|
1163
|
+
else: # The gateway sanitizer should make this unreachable.
|
|
1164
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
1165
|
+
elif request.method == "POST" and path.startswith(_TEAM_PREFIX):
|
|
1166
|
+
remainder = path[len(_TEAM_PREFIX) :]
|
|
1167
|
+
pieces = remainder.split("/")
|
|
1168
|
+
team_id = pieces[0]
|
|
1169
|
+
if len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "server-profile"]:
|
|
1170
|
+
self._query(request, allowed=set())
|
|
1171
|
+
body = self._object_body(request, allowed={"display_name"}, required={"display_name"})
|
|
1172
|
+
result = self.store.rename_network_server(
|
|
1173
|
+
claims, team_id, self._identifier(body["display_name"], maximum=160)
|
|
1174
|
+
)
|
|
1175
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "agents"]:
|
|
1176
|
+
result = self.store.register_network_agent(
|
|
1177
|
+
claims, team_id, self._network_agent_body(request)
|
|
1178
|
+
)
|
|
1179
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "bulletin"]:
|
|
1180
|
+
result = self.store.create_network_bulletin_post(
|
|
1181
|
+
claims,
|
|
1182
|
+
team_id,
|
|
1183
|
+
self._network_text_body(request, mode="bulletin"),
|
|
1184
|
+
)
|
|
1185
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "mailbox"]:
|
|
1186
|
+
result = self.store.create_network_mailbox_item(
|
|
1187
|
+
claims,
|
|
1188
|
+
team_id,
|
|
1189
|
+
self._network_text_body(request, mode="mailbox"),
|
|
1190
|
+
)
|
|
1191
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "requests"]:
|
|
1192
|
+
result = self.store.create_network_request(
|
|
1193
|
+
claims,
|
|
1194
|
+
team_id,
|
|
1195
|
+
self._network_text_body(request, mode="request"),
|
|
1196
|
+
)
|
|
1197
|
+
elif (
|
|
1198
|
+
len(pieces) == 5
|
|
1199
|
+
and pieces[1:3] == [_NETWORK_CHILD, "deliveries"]
|
|
1200
|
+
and pieces[4] == "receipts"
|
|
1201
|
+
):
|
|
1202
|
+
result = self.store.record_network_delivery_receipt(
|
|
1203
|
+
claims,
|
|
1204
|
+
team_id,
|
|
1205
|
+
self._resource_id(pieces[3]),
|
|
1206
|
+
self._network_receipt_body(request),
|
|
1207
|
+
)
|
|
1208
|
+
elif (
|
|
1209
|
+
len(pieces) == 5
|
|
1210
|
+
and pieces[1:3] == [_NETWORK_CHILD, "requests"]
|
|
1211
|
+
and pieces[4] == "replies"
|
|
1212
|
+
):
|
|
1213
|
+
request_id = self._resource_id(pieces[3])
|
|
1214
|
+
reply_body = self._network_text_body(request, mode="reply")
|
|
1215
|
+
existing_request = self.store.get_network_request(
|
|
1216
|
+
claims,
|
|
1217
|
+
team_id,
|
|
1218
|
+
request_id,
|
|
1219
|
+
)
|
|
1220
|
+
request_item = existing_request.get("item")
|
|
1221
|
+
sender = (
|
|
1222
|
+
request_item.get("from")
|
|
1223
|
+
if isinstance(request_item, dict)
|
|
1224
|
+
else None
|
|
1225
|
+
)
|
|
1226
|
+
recipient = (
|
|
1227
|
+
request_item.get("to")
|
|
1228
|
+
if isinstance(request_item, dict)
|
|
1229
|
+
else None
|
|
1230
|
+
)
|
|
1231
|
+
if (
|
|
1232
|
+
not isinstance(sender, dict)
|
|
1233
|
+
or not isinstance(recipient, dict)
|
|
1234
|
+
or sender.get("kind") == "agent"
|
|
1235
|
+
or recipient.get("kind") == "agent"
|
|
1236
|
+
):
|
|
1237
|
+
raise HubError(
|
|
1238
|
+
"invalid_request",
|
|
1239
|
+
"Agent-addressed peer replies are retired",
|
|
1240
|
+
422,
|
|
1241
|
+
)
|
|
1242
|
+
result = self.store.create_network_request_reply(
|
|
1243
|
+
claims,
|
|
1244
|
+
team_id,
|
|
1245
|
+
request_id,
|
|
1246
|
+
reply_body,
|
|
1247
|
+
)
|
|
1248
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "messages"]:
|
|
1249
|
+
result = self.store.create_team_message(
|
|
1250
|
+
claims, team_id, self._team_message_body(request)
|
|
1251
|
+
)
|
|
1252
|
+
elif (
|
|
1253
|
+
len(pieces) == 5
|
|
1254
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1255
|
+
and pieces[4] == "receipts"
|
|
1256
|
+
):
|
|
1257
|
+
result = self.store.record_team_message_receipt(
|
|
1258
|
+
claims,
|
|
1259
|
+
team_id,
|
|
1260
|
+
self._resource_id(pieces[3]),
|
|
1261
|
+
self._team_receipt_body(request),
|
|
1262
|
+
)
|
|
1263
|
+
elif (
|
|
1264
|
+
len(pieces) == 5
|
|
1265
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1266
|
+
and pieces[4] == "revisions"
|
|
1267
|
+
):
|
|
1268
|
+
values = self._team_query(request, allowed={"include_mail_subject"})
|
|
1269
|
+
result = self.store.revise_team_message(
|
|
1270
|
+
claims,
|
|
1271
|
+
team_id,
|
|
1272
|
+
self._resource_id(pieces[3]),
|
|
1273
|
+
self._team_message_revision_body(request),
|
|
1274
|
+
include_mail_subject=self._query_flag(values, "include_mail_subject"),
|
|
1275
|
+
)
|
|
1276
|
+
elif (
|
|
1277
|
+
len(pieces) == 5
|
|
1278
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1279
|
+
and pieces[4] == "dismissals"
|
|
1280
|
+
):
|
|
1281
|
+
result = self.store.dismiss_team_message(
|
|
1282
|
+
claims, team_id, self._resource_id(pieces[3]), self._team_dismissal_body(request)
|
|
1283
|
+
)
|
|
1284
|
+
elif (
|
|
1285
|
+
len(pieces) == 5 and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1286
|
+
and pieces[4] == "mailbox-state"
|
|
1287
|
+
):
|
|
1288
|
+
result = self.store.set_team_message_mailbox_state(
|
|
1289
|
+
claims, team_id, self._resource_id(pieces[3]), self._team_mailbox_state_body(request)
|
|
1290
|
+
)
|
|
1291
|
+
elif len(pieces) == 3 and pieces[1:] == [_NETWORK_CHILD, "attachments"]:
|
|
1292
|
+
result = self.store.declare_team_attachment(
|
|
1293
|
+
claims, team_id, self._team_attachment_body(request)
|
|
1294
|
+
)
|
|
1295
|
+
elif (
|
|
1296
|
+
len(pieces) == 5
|
|
1297
|
+
and pieces[1:3] == [_NETWORK_CHILD, "skills"]
|
|
1298
|
+
and pieces[4] in {"pin", "archive"}
|
|
1299
|
+
):
|
|
1300
|
+
flag = "pinned" if pieces[4] == "pin" else "archived"
|
|
1301
|
+
setter = (
|
|
1302
|
+
self.store.set_team_skill_pinned
|
|
1303
|
+
if flag == "pinned"
|
|
1304
|
+
else self.store.set_team_skill_archived
|
|
1305
|
+
)
|
|
1306
|
+
result = setter(
|
|
1307
|
+
claims,
|
|
1308
|
+
team_id,
|
|
1309
|
+
self._resource_id(pieces[3]),
|
|
1310
|
+
self._team_skill_flag_body(request, flag),
|
|
1311
|
+
)
|
|
1312
|
+
else:
|
|
1313
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
1314
|
+
elif request.method == "DELETE" and path.startswith(_TEAM_PREFIX):
|
|
1315
|
+
remainder = path[len(_TEAM_PREFIX) :]
|
|
1316
|
+
pieces = remainder.split("/")
|
|
1317
|
+
team_id = pieces[0]
|
|
1318
|
+
if (
|
|
1319
|
+
len(pieces) == 4
|
|
1320
|
+
and pieces[1:3] == [_NETWORK_CHILD, "messages"]
|
|
1321
|
+
):
|
|
1322
|
+
result = self.store.delete_team_message(
|
|
1323
|
+
claims,
|
|
1324
|
+
team_id,
|
|
1325
|
+
self._resource_id(pieces[3]),
|
|
1326
|
+
self._network_delete_body(request),
|
|
1327
|
+
)
|
|
1328
|
+
elif (
|
|
1329
|
+
len(pieces) == 4
|
|
1330
|
+
and pieces[1:3] == [_NETWORK_CHILD, "bulletin"]
|
|
1331
|
+
):
|
|
1332
|
+
result = self.store.delete_network_bulletin_post(
|
|
1333
|
+
claims,
|
|
1334
|
+
team_id,
|
|
1335
|
+
self._resource_id(pieces[3]),
|
|
1336
|
+
self._network_delete_body(request),
|
|
1337
|
+
)
|
|
1338
|
+
else:
|
|
1339
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
1340
|
+
elif path.startswith(_CHANNEL_PREFIX) and path.endswith(_MESSAGE_SUFFIX):
|
|
1341
|
+
channel_id = path[len(_CHANNEL_PREFIX) : -len(_MESSAGE_SUFFIX)]
|
|
1342
|
+
if request.method == "GET":
|
|
1343
|
+
values = dict(parse_qsl(request.query, keep_blank_values=True))
|
|
1344
|
+
result = self.store.list_messages(
|
|
1345
|
+
claims,
|
|
1346
|
+
channel_id,
|
|
1347
|
+
int(values.get("limit", "50")),
|
|
1348
|
+
(
|
|
1349
|
+
int(values["before_sequence"])
|
|
1350
|
+
if "before_sequence" in values
|
|
1351
|
+
else None
|
|
1352
|
+
),
|
|
1353
|
+
)
|
|
1354
|
+
elif request.method == "POST":
|
|
1355
|
+
result = self.store.create_message(
|
|
1356
|
+
claims,
|
|
1357
|
+
channel_id,
|
|
1358
|
+
self._message_body(request),
|
|
1359
|
+
)
|
|
1360
|
+
else:
|
|
1361
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
1362
|
+
else:
|
|
1363
|
+
raise HubError("not_found", "Resource not found", 404)
|
|
1364
|
+
return self._json(200, result)
|
|
1365
|
+
except HubError as exc:
|
|
1366
|
+
return self._json(
|
|
1367
|
+
exc.status_code,
|
|
1368
|
+
{"error": {"code": exc.code, "message": exc.message}},
|
|
1369
|
+
)
|
|
1370
|
+
finally:
|
|
1371
|
+
if admitted:
|
|
1372
|
+
self._release(request.peer.peer_id)
|