@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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)