@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,1737 @@
1
+ """Strict loopback-first HTTP boundary for AgentsDock Team Hub V1."""
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import ipaddress
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import threading
13
+ import time
14
+ from typing import Annotated, Any, Callable, Literal
15
+ from urllib.parse import quote, urlsplit
16
+
17
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
18
+ from fastapi.exceptions import RequestValidationError
19
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
21
+
22
+ from .auth import AuthenticationError, AuthorizationError
23
+ from .secure_peer import AttachmentFileLease
24
+ from .store import (
25
+ MAX_NETWORK_BODY_BYTES,
26
+ MAX_NETWORK_PAGE_ITEMS,
27
+ MAX_HUMAN_ADMIN_PAGE_ITEMS,
28
+ MAX_TEAM_MESSAGE_ATTACHMENTS,
29
+ MAX_TEAM_MESSAGE_BODY_BYTES,
30
+ MAX_TEAM_MESSAGE_RECIPIENTS,
31
+ MAX_TEAM_MAIL_THREAD_PAGE_ITEMS,
32
+ MAX_TEAM_SKILL_TAGS,
33
+ TEAM_ATTACHMENT_CHUNK_BYTES,
34
+ AccessClaims,
35
+ HubError,
36
+ HubStore,
37
+ )
38
+
39
+
40
+ MAX_JSON_BODY_BYTES = 65_536
41
+ # Team attachment bytes use one narrowly allowlisted binary lane instead of the
42
+ # JSON body pipeline: PUT chunks up to TEAM_ATTACHMENT_CHUNK_BYTES, GET/HEAD
43
+ # with Range. Nothing else escapes the JSON limits below.
44
+ TEAM_ATTACHMENT_CONTENT_RE = re.compile(
45
+ r"^/v1/teams/[^/]+/network/attachments/[^/]+/content$"
46
+ )
47
+ _CONTENT_RANGE_RE = re.compile(
48
+ r"^bytes (?P<start>[0-9]{1,15})-(?P<end>[0-9]{1,15})/(?P<total>[0-9]{1,15})$"
49
+ )
50
+ _RANGE_RE = re.compile(r"^bytes=(?P<start>[0-9]{0,15})-(?P<end>[0-9]{0,15})$")
51
+ _ATTACHMENT_STREAM_BLOCK_BYTES = 1024 * 1024
52
+ BODY_READ_TIMEOUT_SECONDS = 10.0
53
+ HOSTNAME_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$")
54
+ TAILSCALE_SERVE_HEADERS_INFO = "https://tailscale.com/s/serve-headers"
55
+ # Set only by the authenticated parent AgentsServer mount. HTTP clients cannot
56
+ # manufacture ASGI scope extensions, so this keeps the core server credential
57
+ # out of the Team Hub credential realm while allowing every client authorized
58
+ # to the same AgentsServer to share its server-scoped Teamspace identity.
59
+ MANAGED_SERVER_SESSION_SCOPE_KEY = "agentsdock.team_hub.managed_server_session"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ManagedTransportIdentity:
64
+ kind: Literal["loopback", "tailscale_serve", "direct_ip"]
65
+ tailnet_login: str | None = None
66
+ tailnet_user_name: str | None = None
67
+
68
+
69
+ @dataclass
70
+ class _RateBucket:
71
+ bins: deque[tuple[int, int]]
72
+ count: int
73
+ last_seen: float
74
+
75
+
76
+ class _RateLimiter:
77
+ """Bounded rolling-window limiter with one-second accounting bins."""
78
+
79
+ def __init__(self, *, clock: Callable[[], float] | None = None) -> None:
80
+ self._lock = threading.Lock()
81
+ self._clock = time.monotonic if clock is None else clock
82
+ self._buckets: dict[tuple[str, str], _RateBucket] = {}
83
+
84
+ def _prune_locked(self, now: float) -> None:
85
+ if len(self._buckets) <= 4096:
86
+ return
87
+ stale_before = now - 61.0
88
+ self._buckets = {
89
+ key: bucket
90
+ for key, bucket in self._buckets.items()
91
+ if bucket.last_seen > stale_before
92
+ }
93
+ if len(self._buckets) > 4096:
94
+ oldest = sorted(
95
+ self._buckets,
96
+ key=lambda key: self._buckets[key].last_seen,
97
+ )
98
+ for key in oldest[: len(self._buckets) - 4096]:
99
+ self._buckets.pop(key, None)
100
+
101
+ def allow(self, peer: str, action: str, limit: int) -> bool:
102
+ if limit <= 0:
103
+ return False
104
+ key = (peer, action)
105
+ with self._lock:
106
+ # Sample the clock only after acquiring the mutation lock. Two
107
+ # callers can otherwise sample in chronological order but acquire
108
+ # the lock in reverse order, leaving the pruning deque unsorted
109
+ # and regressing last_seen.
110
+ now = self._clock()
111
+ current_second = int(now)
112
+ cutoff = now - 60.0
113
+ bucket = self._buckets.get(key)
114
+ if bucket is None:
115
+ bucket = _RateBucket(deque(), 0, now)
116
+ self._buckets[key] = bucket
117
+ while bucket.bins and bucket.bins[0][0] + 1.0 <= cutoff:
118
+ _second, expired_count = bucket.bins.popleft()
119
+ bucket.count -= expired_count
120
+ bucket.last_seen = now
121
+ if bucket.count >= limit:
122
+ self._prune_locked(now)
123
+ return False
124
+ if bucket.bins and bucket.bins[-1][0] == current_second:
125
+ second, count = bucket.bins[-1]
126
+ bucket.bins[-1] = (second, count + 1)
127
+ else:
128
+ bucket.bins.append((current_second, 1))
129
+ bucket.count += 1
130
+ self._prune_locked(now)
131
+ return True
132
+
133
+
134
+ class StrictModel(BaseModel):
135
+ model_config = ConfigDict(extra="forbid", strict=True)
136
+
137
+
138
+ class BootstrapRequest(StrictModel):
139
+ email: str = Field(min_length=3, max_length=320)
140
+ display_name: str = Field(min_length=1, max_length=160)
141
+ device_label: str = Field(min_length=1, max_length=160)
142
+
143
+
144
+ class RefreshRequest(StrictModel):
145
+ refresh_token: str = Field(min_length=16, max_length=512)
146
+
147
+
148
+ class RevokeSessionRequest(StrictModel):
149
+ refresh_token: str = Field(min_length=16, max_length=512)
150
+
151
+
152
+ class InviteRequest(StrictModel):
153
+ invitee_email: str = Field(min_length=3, max_length=320)
154
+ role: Literal["admin", "member", "guest"]
155
+ ttl_seconds: int = Field(default=900, ge=30, le=86_400)
156
+
157
+
158
+ class MembershipUpdateRequest(StrictModel):
159
+ role: Literal["admin", "member", "guest"] | None = None
160
+ status: Literal["active", "suspended", "revoked"] | None = None
161
+
162
+ @model_validator(mode="after")
163
+ def exactly_one_change(self) -> "MembershipUpdateRequest":
164
+ if (self.role is None) == (self.status is None):
165
+ raise ValueError("exactly one membership change is required")
166
+ return self
167
+
168
+
169
+ class RedeemInviteRequest(StrictModel):
170
+ token: str = Field(min_length=16, max_length=512)
171
+ email: str = Field(min_length=3, max_length=320)
172
+ display_name: str = Field(min_length=1, max_length=160)
173
+ device_label: str = Field(min_length=1, max_length=160)
174
+
175
+
176
+ class AcceptInviteRequest(StrictModel):
177
+ token: str = Field(min_length=16, max_length=512)
178
+
179
+
180
+ class RedeemRecoveryRequest(StrictModel):
181
+ device_label: str = Field(min_length=1, max_length=160)
182
+
183
+
184
+ class NodeGrantRequest(StrictModel):
185
+ server_identity: str = Field(min_length=8, max_length=240)
186
+ display_name: str = Field(min_length=1, max_length=160)
187
+ public_key: str = Field(min_length=32, max_length=16_384)
188
+ ttl_seconds: int = Field(default=300, ge=30, le=900)
189
+
190
+
191
+ class NodeChallengeRequest(StrictModel):
192
+ token: str = Field(min_length=16, max_length=512)
193
+ server_identity: str = Field(min_length=8, max_length=240)
194
+ display_name: str = Field(min_length=1, max_length=160)
195
+ public_key: str = Field(min_length=32, max_length=16_384)
196
+
197
+
198
+ class NodeRedeemRequest(StrictModel):
199
+ challenge_id: str = Field(min_length=8, max_length=240)
200
+ signature: str = Field(min_length=80, max_length=128)
201
+
202
+
203
+ class ChannelRequest(StrictModel):
204
+ kind: Literal["board", "announcements", "direct"]
205
+ visibility: Literal["team", "private"] = "team"
206
+ slug: str | None = Field(default=None, min_length=1, max_length=80)
207
+ display_name: str | None = Field(default=None, min_length=1, max_length=160)
208
+ participant_principal_ids: list[str] = Field(default_factory=list, max_length=256)
209
+ idempotency_key: str = Field(min_length=8, max_length=240)
210
+
211
+
212
+ class MessageRequest(StrictModel):
213
+ body: str = Field(min_length=1, max_length=65_536)
214
+ body_format: Literal["plain", "markdown"] = "markdown"
215
+ kind: Literal["post", "announcement"] = "post"
216
+ thread_root_message_id: str | None = Field(default=None, min_length=1, max_length=240)
217
+ parent_message_id: str | None = Field(default=None, min_length=1, max_length=240)
218
+ idempotency_key: str = Field(min_length=8, max_length=240)
219
+
220
+
221
+ class NetworkAddress(StrictModel):
222
+ # Cross-server delivery terminates at the remote server Inbox. Agent
223
+ # identities remain directory/read metadata, never writable addresses.
224
+ kind: Literal["server"]
225
+ id: str = Field(min_length=1, max_length=240)
226
+
227
+
228
+ class NetworkAgentRequest(StrictModel):
229
+ external_agent_id: str = Field(min_length=1, max_length=240)
230
+ backend: Literal["codex", "claude", "other"]
231
+ display_name: str = Field(min_length=1, max_length=160)
232
+ idempotency_key: str = Field(min_length=8, max_length=240)
233
+
234
+
235
+ class NetworkBulletinRequest(StrictModel):
236
+ body: str = Field(min_length=1, max_length=MAX_NETWORK_BODY_BYTES)
237
+ body_format: Literal["plain", "markdown"] = "markdown"
238
+ reply_to_post_id: str | None = Field(default=None, min_length=1, max_length=240)
239
+ idempotency_key: str = Field(min_length=8, max_length=240)
240
+
241
+
242
+ class NetworkDeleteRequest(StrictModel):
243
+ idempotency_key: str = Field(min_length=8, max_length=240)
244
+
245
+
246
+ class NetworkMailboxRequest(StrictModel):
247
+ to: NetworkAddress
248
+ from_agent_id: None = None
249
+ body: str = Field(min_length=1, max_length=MAX_NETWORK_BODY_BYTES)
250
+ body_format: Literal["plain", "markdown"] = "markdown"
251
+ idempotency_key: str = Field(min_length=8, max_length=240)
252
+
253
+
254
+ class NetworkPassiveRequest(NetworkMailboxRequest):
255
+ expires_in_seconds: int = Field(default=86_400, ge=60, le=86_400)
256
+
257
+
258
+ class NetworkReplyRequest(StrictModel):
259
+ from_agent_id: None = None
260
+ body: str = Field(min_length=1, max_length=MAX_NETWORK_BODY_BYTES)
261
+ body_format: Literal["plain", "markdown"] = "markdown"
262
+ idempotency_key: str = Field(min_length=8, max_length=240)
263
+
264
+
265
+ class NetworkReceiptRequest(StrictModel):
266
+ state: Literal["delivered", "read"]
267
+ idempotency_key: str = Field(min_length=8, max_length=240)
268
+
269
+
270
+ class TeamRecipientRequest(StrictModel):
271
+ kind: Literal["server", "human", "all", "all_servers"]
272
+ id: str | None = Field(default=None, min_length=1, max_length=240)
273
+ mail_route_lifecycle_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
274
+
275
+
276
+ class TeamSkillDetailsRequest(StrictModel):
277
+ slug: str = Field(min_length=1, max_length=64)
278
+ summary: str = Field(default="", max_length=280)
279
+ tags: list[str] = Field(default_factory=list, max_length=MAX_TEAM_SKILL_TAGS)
280
+ change_note: str = Field(default="", max_length=280)
281
+ expected_version: int | None = Field(default=None, ge=1)
282
+
283
+
284
+ class TeamMessageRequest(StrictModel):
285
+ kind: Literal["message", "skill"]
286
+ title: str | None = Field(default=None, min_length=1, max_length=160)
287
+ body: str = Field(min_length=1, max_length=MAX_TEAM_MESSAGE_BODY_BYTES)
288
+ body_format: Literal["plain", "markdown"] = "markdown"
289
+ recipients: list[TeamRecipientRequest] = Field(
290
+ min_length=1, max_length=MAX_TEAM_MESSAGE_RECIPIENTS
291
+ )
292
+ attachment_ids: list[str] = Field(
293
+ default_factory=list, max_length=MAX_TEAM_MESSAGE_ATTACHMENTS
294
+ )
295
+ in_reply_to_message_id: str | None = Field(default=None, min_length=8, max_length=240)
296
+ skill: TeamSkillDetailsRequest | None = None
297
+ provenance: dict[str, str | None] | None = None
298
+ idempotency_key: str = Field(min_length=8, max_length=240)
299
+
300
+ @model_validator(mode="before")
301
+ @classmethod
302
+ def normalize_mail_subject(cls, value: Any) -> Any:
303
+ if isinstance(value, dict) and value.get("kind") == "message" and value.get("title") is not None:
304
+ try:
305
+ subject = HubStore._team_mail_subject(value["title"])
306
+ except HubError as exc:
307
+ raise ValueError(exc.message) from exc
308
+ return {**value, "title": subject}
309
+ return value
310
+
311
+ @field_validator("provenance")
312
+ @classmethod
313
+ def validate_provenance_keys(
314
+ cls, value: dict[str, str | None] | None
315
+ ) -> dict[str, str | None] | None:
316
+ if value is not None and not set(value).issubset(
317
+ {"via", "backend", "chat_id", "run_id"}
318
+ ):
319
+ raise ValueError("Team Message provenance contains unknown fields")
320
+ return value
321
+
322
+
323
+ class TeamMessageRevisionRequest(StrictModel):
324
+ body: str = Field(min_length=1, max_length=MAX_TEAM_MESSAGE_BODY_BYTES)
325
+ body_format: Literal["plain", "markdown"] = "markdown"
326
+ expected_version: int = Field(ge=1)
327
+ idempotency_key: str = Field(min_length=8, max_length=240)
328
+
329
+
330
+ class TeamReceiptRequest(StrictModel):
331
+ state: Literal["delivered", "read"]
332
+ idempotency_key: str = Field(min_length=8, max_length=240)
333
+ address_kind: Literal["server", "human"] | None = None
334
+ address_id: str | None = Field(default=None, min_length=8, max_length=240)
335
+
336
+
337
+ class TeamMessageDismissalRequest(StrictModel):
338
+ address_kind: Literal["server", "human"]
339
+ address_id: str = Field(min_length=8, max_length=240)
340
+ idempotency_key: str = Field(min_length=8, max_length=240)
341
+
342
+
343
+ class TeamMailboxStateRequest(StrictModel):
344
+ address_kind: Literal["server"]
345
+ address_id: str = Field(min_length=8, max_length=240)
346
+ unread: bool = Field(strict=True)
347
+ expected_version: int = Field(strict=True, ge=0, lt=9_007_199_254_740_991)
348
+ idempotency_key: str = Field(min_length=8, max_length=240)
349
+
350
+
351
+ class TeamAttachmentDeclareRequest(StrictModel):
352
+ file_name: str = Field(min_length=1, max_length=255)
353
+ media_type: str = Field(min_length=3, max_length=160)
354
+ byte_size: int = Field(ge=1)
355
+ sha256: str = Field(min_length=64, max_length=64)
356
+ idempotency_key: str = Field(min_length=8, max_length=240)
357
+
358
+
359
+ class TeamSkillPinRequest(StrictModel):
360
+ pinned: bool
361
+ idempotency_key: str = Field(min_length=8, max_length=240)
362
+
363
+
364
+ class TeamSkillArchiveRequest(StrictModel):
365
+ archived: bool
366
+ idempotency_key: str = Field(min_length=8, max_length=240)
367
+
368
+
369
+ def attachment_content_response(
370
+ request: Request,
371
+ attachment: dict[str, Any],
372
+ path: Path | int | AttachmentFileLease,
373
+ ) -> Response:
374
+ """Stream one ready attachment with byte-range support for video seeking."""
375
+
376
+ size = int(attachment["byte_size"])
377
+ headers = {
378
+ "Accept-Ranges": "bytes",
379
+ "Content-Disposition": (
380
+ "inline; filename*=UTF-8''" + quote(str(attachment["file_name"]), safe="")
381
+ ),
382
+ "Cache-Control": "private, max-age=0",
383
+ "X-Content-Type-Options": "nosniff",
384
+ "ETag": f'"{attachment["sha256"]}"',
385
+ }
386
+ start, end, status = 0, size - 1, 200
387
+ descriptor = path.descriptor if isinstance(path, AttachmentFileLease) else path
388
+
389
+ def close_source() -> None:
390
+ if isinstance(path, AttachmentFileLease):
391
+ path.close()
392
+ elif type(path) is int:
393
+ try:
394
+ os.close(path)
395
+ except OSError:
396
+ pass
397
+
398
+ range_header = request.headers.get("range")
399
+ if range_header:
400
+ match = _RANGE_RE.fullmatch(range_header.strip())
401
+ invalid = match is None
402
+ if not invalid:
403
+ raw_start, raw_end = match["start"], match["end"]
404
+ if raw_start == "" and raw_end == "":
405
+ invalid = True
406
+ elif raw_start == "":
407
+ suffix = int(raw_end)
408
+ if suffix == 0:
409
+ invalid = True
410
+ else:
411
+ start, end = max(0, size - suffix), size - 1
412
+ else:
413
+ start = int(raw_start)
414
+ end = int(raw_end) if raw_end else size - 1
415
+ if start >= size or end < start:
416
+ invalid = True
417
+ end = min(end, size - 1)
418
+ if invalid:
419
+ close_source()
420
+ return Response(
421
+ status_code=416,
422
+ headers={**headers, "Content-Range": f"bytes */{size}"},
423
+ )
424
+ status = 206
425
+ headers["Content-Range"] = f"bytes {start}-{end}/{size}"
426
+ length = end - start + 1
427
+ headers["Content-Length"] = str(length)
428
+ media_type = str(attachment["media_type"])
429
+ if request.method == "HEAD":
430
+ close_source()
431
+ return Response(status_code=status, headers=headers, media_type=media_type)
432
+
433
+ def stream():
434
+ if type(descriptor) is int:
435
+ try:
436
+ offset = start
437
+ remaining = length
438
+ while remaining > 0:
439
+ block = os.pread(
440
+ descriptor,
441
+ min(_ATTACHMENT_STREAM_BLOCK_BYTES, remaining),
442
+ offset,
443
+ )
444
+ if not block:
445
+ break
446
+ offset += len(block)
447
+ remaining -= len(block)
448
+ yield block
449
+ finally:
450
+ close_source()
451
+ return
452
+ with open(path, "rb") as handle:
453
+ handle.seek(start)
454
+ remaining = length
455
+ while remaining > 0:
456
+ block = handle.read(min(_ATTACHMENT_STREAM_BLOCK_BYTES, remaining))
457
+ if not block:
458
+ break
459
+ remaining -= len(block)
460
+ yield block
461
+
462
+ return StreamingResponse(
463
+ stream(), status_code=status, headers=headers, media_type=media_type
464
+ )
465
+
466
+
467
+ SecurePeerScope = Literal[
468
+ "teamspace.read",
469
+ "teamspace.write",
470
+ "cross_chat.instruction",
471
+ "cross_chat.request_reply",
472
+ ]
473
+
474
+
475
+ class SecurePeerApprovalRequest(StrictModel):
476
+ idempotency_key: str = Field(min_length=8, max_length=128)
477
+ sas_confirmed: Literal[True]
478
+ expected_peer_server_identity: str = Field(min_length=8, max_length=240)
479
+ expected_transcript_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
480
+ scopes: list[SecurePeerScope] = Field(min_length=1, max_length=4)
481
+
482
+
483
+ class SecurePeerRejectionRequest(StrictModel):
484
+ idempotency_key: str = Field(min_length=8, max_length=128)
485
+ expected_peer_server_identity: str = Field(min_length=8, max_length=240)
486
+ expected_transcript_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
487
+ reason: str | None = Field(default=None, min_length=1, max_length=160)
488
+
489
+
490
+ class SecurePeerRevocationRequest(StrictModel):
491
+ idempotency_key: str = Field(min_length=8, max_length=128)
492
+ expected_certificate_fingerprint: str = Field(
493
+ pattern=r"^sha256:[0-9a-f]{64}$"
494
+ )
495
+
496
+
497
+ def _error(code: str, message: str, status_code: int) -> JSONResponse:
498
+ return JSONResponse(status_code=status_code, content={"error": {"code": code, "message": message}})
499
+
500
+
501
+ def _is_loopback_peer(request: Request) -> bool:
502
+ if request.client is None:
503
+ return False
504
+ try:
505
+ return ipaddress.ip_address(request.client.host).is_loopback
506
+ except ValueError:
507
+ return False
508
+
509
+
510
+ def _has_loopback_authority(request: Request) -> bool:
511
+ value = _exact_ascii_header(request, b"host")
512
+ if value is None:
513
+ return False
514
+ host = _host_name(value)
515
+ if host is None:
516
+ return False
517
+ if host == "localhost":
518
+ return True
519
+ try:
520
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
521
+ except ValueError:
522
+ return False
523
+
524
+
525
+ def _host_name(authority: str) -> str | None:
526
+ if not authority or "@" in authority or any(ord(char) < 33 or ord(char) > 126 for char in authority):
527
+ return None
528
+ port: str | None = None
529
+ if authority.startswith("["):
530
+ match = re.fullmatch(r"\[([^\]]+)\](?::([0-9]+))?", authority)
531
+ if match is None:
532
+ return None
533
+ try:
534
+ address = ipaddress.ip_address(match.group(1))
535
+ except ValueError:
536
+ return None
537
+ if address.version != 6:
538
+ return None
539
+ host = f"[{address.compressed}]"
540
+ port = match.group(2)
541
+ elif authority.count(":") > 1:
542
+ try:
543
+ address = ipaddress.ip_address(authority)
544
+ except ValueError:
545
+ return None
546
+ if address.version != 6:
547
+ return None
548
+ host = address.compressed
549
+ else:
550
+ if ":" in authority:
551
+ host, port = authority.rsplit(":", 1)
552
+ else:
553
+ host = authority
554
+ if HOSTNAME_PATTERN.fullmatch(host) is None:
555
+ return None
556
+ if port is not None and (not port.isdigit() or not 1 <= int(port) <= 65535):
557
+ return None
558
+ return host.lower()
559
+
560
+
561
+ def _exact_ascii_header(request: Request, name: bytes) -> str | None:
562
+ values = [
563
+ value
564
+ for key, value in request.scope.get("headers", [])
565
+ if key.lower() == name
566
+ ]
567
+ if len(values) != 1:
568
+ return None
569
+ try:
570
+ return values[0].decode("ascii")
571
+ except UnicodeDecodeError:
572
+ return None
573
+
574
+
575
+ def classify_managed_transport(
576
+ request: Request,
577
+ *,
578
+ managed_transport: str,
579
+ managed_hub_url: str,
580
+ ) -> ManagedTransportIdentity | None:
581
+ """Classify only a direct loopback call or the configured Serve proxy.
582
+
583
+ Uvicorn deliberately runs with ``proxy_headers=False``. Therefore the
584
+ socket peer and ASGI scheme remain the local Serve proxy and ``http``;
585
+ forwarded values are evidence only when every Serve-owned header exactly
586
+ matches the one configured public origin.
587
+ """
588
+
589
+ if request.url.scheme != "http":
590
+ return None
591
+ raw_names = [key.lower() for key, _value in request.scope.get("headers", [])]
592
+ forwarded_names = {
593
+ b"forwarded",
594
+ b"x-forwarded-host",
595
+ b"x-forwarded-proto",
596
+ b"tailscale-user-login",
597
+ b"tailscale-user-name",
598
+ b"tailscale-headers-info",
599
+ b"tailscale-funnel-request",
600
+ }
601
+ loopback_peer = _is_loopback_peer(request)
602
+ if loopback_peer and _has_loopback_authority(request):
603
+ if any(name in forwarded_names for name in raw_names):
604
+ return None
605
+ return ManagedTransportIdentity("loopback")
606
+ if managed_transport == "direct_ip":
607
+ direct_forbidden_headers = forwarded_names | {
608
+ b"cookie",
609
+ b"x-forwarded-for",
610
+ b"x-forwarded-port",
611
+ b"via",
612
+ }
613
+ if loopback_peer or any(name in direct_forbidden_headers for name in raw_names):
614
+ return None
615
+ try:
616
+ parsed = urlsplit(managed_hub_url)
617
+ except ValueError:
618
+ return None
619
+ if (
620
+ parsed.scheme != "http"
621
+ or not parsed.netloc
622
+ or _exact_ascii_header(request, b"host") != parsed.netloc
623
+ ):
624
+ return None
625
+ return ManagedTransportIdentity("direct_ip")
626
+ if not loopback_peer:
627
+ return None
628
+ if managed_transport != "tailscale_serve":
629
+ return None
630
+ try:
631
+ parsed = urlsplit(managed_hub_url)
632
+ except ValueError:
633
+ return None
634
+ expected_authority = parsed.netloc
635
+ if (
636
+ parsed.scheme != "https"
637
+ or not expected_authority
638
+ or _exact_ascii_header(request, b"host") != expected_authority
639
+ or _exact_ascii_header(request, b"x-forwarded-host") != expected_authority
640
+ or _exact_ascii_header(request, b"x-forwarded-proto") != "https"
641
+ or _exact_ascii_header(request, b"tailscale-headers-info")
642
+ != TAILSCALE_SERVE_HEADERS_INFO
643
+ or b"tailscale-funnel-request" in raw_names
644
+ or b"forwarded" in raw_names
645
+ ):
646
+ return None
647
+ login = _exact_ascii_header(request, b"tailscale-user-login")
648
+ user_name = _exact_ascii_header(request, b"tailscale-user-name")
649
+ if (
650
+ login is None
651
+ or user_name is None
652
+ or login != login.strip().lower()
653
+ or not 3 <= len(login) <= 320
654
+ or "@" not in login
655
+ or not 1 <= len(user_name.strip()) <= 160
656
+ or any(ord(char) < 32 or ord(char) > 126 for char in login + user_name)
657
+ ):
658
+ return None
659
+ return ManagedTransportIdentity(
660
+ "tailscale_serve",
661
+ tailnet_login=login,
662
+ tailnet_user_name=user_name.strip(),
663
+ )
664
+
665
+
666
+ def _mounted_route_path(request: Request) -> str:
667
+ """Return the route-local path for standalone and mounted deployments."""
668
+
669
+ path = str(request.scope.get("path") or request.url.path)
670
+ root_path = str(request.scope.get("root_path") or "").rstrip("/")
671
+ if root_path and path.startswith(root_path + "/"):
672
+ return path[len(root_path) :]
673
+ return path
674
+
675
+
676
+ def create_app(
677
+ data_dir: str | Path,
678
+ *,
679
+ allowed_hosts: set[str] | None = None,
680
+ allowed_origins: set[str] | None = None,
681
+ managed_host_identity: str | None = None,
682
+ managed_server_instance_id: str | None = None,
683
+ managed_host_display_name: str = "Team Hub host",
684
+ managed_transport: str | None = None,
685
+ managed_hub_url: str | None = None,
686
+ managed_routes: dict[str, str] | None = None,
687
+ secure_peer_manager: Any | None = None,
688
+ managed_reactivation_hub_id: str | None = None,
689
+ managed_reactivation_operation_id: str | None = None,
690
+ managed_reactivation_snapshot: Path | None = None,
691
+ managed_update_hub_id: str | None = None,
692
+ managed_update_operation_id: str | None = None,
693
+ managed_update_snapshot: Path | None = None,
694
+ require_https_for_non_loopback: bool = False,
695
+ require_loopback_transport: bool = False,
696
+ ) -> FastAPI:
697
+ store = HubStore(
698
+ Path(data_dir),
699
+ managed_host_identity=managed_host_identity,
700
+ managed_server_instance_id=managed_server_instance_id,
701
+ managed_host_display_name=managed_host_display_name,
702
+ managed_reactivation_hub_id=managed_reactivation_hub_id,
703
+ managed_reactivation_operation_id=managed_reactivation_operation_id,
704
+ managed_reactivation_snapshot=managed_reactivation_snapshot,
705
+ managed_update_hub_id=managed_update_hub_id,
706
+ managed_update_operation_id=managed_update_operation_id,
707
+ managed_update_snapshot=managed_update_snapshot,
708
+ )
709
+ hosts = allowed_hosts or {"127.0.0.1", "localhost", "[::1]", "::1"}
710
+ origins = allowed_origins or set()
711
+ rate_limiter = _RateLimiter()
712
+ app = FastAPI(
713
+ title="AgentsDock Team Hub",
714
+ version="1",
715
+ docs_url=None,
716
+ redoc_url=None,
717
+ openapi_url="/v1/openapi.json",
718
+ )
719
+ app.state.store = store
720
+ app.state.rate_limiter = rate_limiter
721
+ app.state.secure_peer_manager = secure_peer_manager
722
+
723
+ @app.middleware("http")
724
+ async def strict_transport(request: Request, call_next):
725
+ raw_headers = request.scope.get("headers", [])
726
+ managed_server_session = (
727
+ request.scope.get(MANAGED_SERVER_SESSION_SCOPE_KEY) is True
728
+ )
729
+ values: dict[bytes, list[bytes]] = {}
730
+ for key, value in raw_headers:
731
+ values.setdefault(key.lower(), []).append(value)
732
+ host_values = values.get(b"host", [])
733
+ if len(host_values) != 1:
734
+ return _error("invalid_request", "Invalid Host header", 400)
735
+ try:
736
+ host_header = host_values[0].decode("ascii")
737
+ except UnicodeDecodeError:
738
+ return _error("invalid_request", "Invalid Host header", 400)
739
+ host_name = _host_name(host_header)
740
+ if host_name is None or (
741
+ not managed_server_session
742
+ and host_name not in {item.lower() for item in hosts}
743
+ ):
744
+ return _error("invalid_request", "Invalid Host header", 400)
745
+ managed_identity: ManagedTransportIdentity | None = None
746
+ managed_identity_url: str | None = None
747
+ configured_routes = managed_routes or (
748
+ {managed_transport: managed_hub_url}
749
+ if managed_transport is not None and managed_hub_url is not None
750
+ else {}
751
+ )
752
+ if not managed_server_session and (
753
+ managed_transport is not None or managed_routes is not None
754
+ ):
755
+ if not configured_routes:
756
+ return _error("transport_configuration_invalid", "Transport is unavailable", 503)
757
+ for route_kind, route_url in configured_routes.items():
758
+ managed_identity = classify_managed_transport(
759
+ request,
760
+ managed_transport=route_kind,
761
+ managed_hub_url=route_url,
762
+ )
763
+ if managed_identity is not None:
764
+ managed_identity_url = route_url
765
+ break
766
+ if managed_identity is None:
767
+ return _error(
768
+ "local_preview_only",
769
+ "Embedded Team Hub transport is not permitted",
770
+ 403,
771
+ )
772
+ request.state.team_hub_transport = managed_identity.kind
773
+ request.state.team_hub_url = managed_identity_url
774
+ request.state.tailnet_login = managed_identity.tailnet_login
775
+ origin_values = values.get(b"origin", [])
776
+ if len(origin_values) > 1:
777
+ return _error("origin_forbidden", "Origin is not permitted", 403)
778
+ if origin_values:
779
+ try:
780
+ origin = origin_values[0].decode("ascii")
781
+ except UnicodeDecodeError:
782
+ return _error("origin_forbidden", "Origin is not permitted", 403)
783
+ if origin not in origins:
784
+ return _error("origin_forbidden", "Origin is not permitted", 403)
785
+ else:
786
+ origin = None
787
+ fetch_site = values.get(b"sec-fetch-site", [])
788
+ if fetch_site and fetch_site[-1].lower() == b"cross-site":
789
+ return _error("origin_forbidden", "Origin is not permitted", 403)
790
+ if require_loopback_transport and (
791
+ not _is_loopback_peer(request)
792
+ or not _has_loopback_authority(request)
793
+ or request.url.scheme != "http"
794
+ ):
795
+ return _error(
796
+ "local_preview_only",
797
+ "Embedded Team Hub is available only to this host",
798
+ 403,
799
+ )
800
+ if (
801
+ require_https_for_non_loopback
802
+ and not _is_loopback_peer(request)
803
+ and request.url.scheme != "https"
804
+ ):
805
+ return _error(
806
+ "transport_security_required",
807
+ "Direct HTTPS is required for remote Team Hub access",
808
+ 403,
809
+ )
810
+ if request.method == "OPTIONS":
811
+ requested_method = request.headers.get("access-control-request-method")
812
+ if origin is None or requested_method not in {
813
+ "GET",
814
+ "POST",
815
+ "PUT",
816
+ "PATCH",
817
+ "DELETE",
818
+ }:
819
+ return _error("origin_forbidden", "Origin is not permitted", 403)
820
+ requested_headers = request.headers.get("access-control-request-headers", "")
821
+ allowed_request_headers = {
822
+ item.strip().lower() for item in requested_headers.split(",") if item.strip()
823
+ }
824
+ if not allowed_request_headers.issubset(
825
+ {
826
+ "authorization",
827
+ "content-range",
828
+ "content-type",
829
+ "x-team-hub-bootstrap-proof",
830
+ "x-team-hub-bootstrap-request-id",
831
+ "x-team-hub-device-recovery-proof",
832
+ "x-team-hub-owner-recovery-proof",
833
+ }
834
+ ):
835
+ return _error("origin_forbidden", "Origin is not permitted", 403)
836
+ return Response(
837
+ status_code=204,
838
+ headers={
839
+ "Access-Control-Allow-Origin": origin,
840
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE",
841
+ "Access-Control-Allow-Headers": requested_headers,
842
+ "Vary": "Origin",
843
+ },
844
+ )
845
+
846
+ def with_allowed_origin(response: Response) -> Response:
847
+ if origin is not None:
848
+ response.headers["Access-Control-Allow-Origin"] = origin
849
+ response.headers["Vary"] = "Origin"
850
+ return response
851
+
852
+ binary_lane_path = _mounted_route_path(request)
853
+ binary_upload = (
854
+ request.method == "PUT"
855
+ and TEAM_ATTACHMENT_CONTENT_RE.fullmatch(binary_lane_path) is not None
856
+ )
857
+ unsafe_method = request.method not in {"GET", "HEAD", "OPTIONS"}
858
+ if unsafe_method:
859
+ try:
860
+ maintenance_fenced = store.maintenance_fence() is not None
861
+ except Exception:
862
+ maintenance_fenced = True
863
+ if maintenance_fenced:
864
+ return with_allowed_origin(
865
+ _error(
866
+ "hub_maintenance",
867
+ "Team Hub is unavailable during server maintenance",
868
+ 503,
869
+ )
870
+ )
871
+ if (
872
+ managed_identity is not None
873
+ and managed_identity.kind == "tailscale_serve"
874
+ and managed_identity.tailnet_login is not None
875
+ ):
876
+ peer = "tailnet:" + hashlib.sha256(
877
+ managed_identity.tailnet_login.encode("utf-8")
878
+ ).hexdigest()
879
+ else:
880
+ peer = request.client.host if request.client is not None else "unknown"
881
+ route_path = binary_lane_path
882
+ sensitive_limits = {
883
+ "/v1/bootstrap/redeem": 8,
884
+ "/v1/owner-recovery/redeem": 8,
885
+ "/v1/device-recovery/redeem": 8,
886
+ "/v1/sessions/refresh": 30,
887
+ "/v1/invitations/redeem": 30,
888
+ "/v1/node-enrollments/challenge": 30,
889
+ "/v1/node-enrollments/redeem": 30,
890
+ }
891
+ if binary_upload:
892
+ action, limit = "attachment_upload", 1_200
893
+ else:
894
+ action = (
895
+ route_path
896
+ if route_path in sensitive_limits
897
+ else f"other_{request.method.lower()}"
898
+ )
899
+ limit = sensitive_limits.get(route_path, 120)
900
+ if (
901
+ not rate_limiter.allow(peer, action, limit)
902
+ or not rate_limiter.allow("*", action, limit * 100)
903
+ ):
904
+ return with_allowed_origin(
905
+ _error("rate_limited", "Too many requests", 429)
906
+ )
907
+ if values.get(b"transfer-encoding"):
908
+ return with_allowed_origin(
909
+ _error("invalid_request", "Transfer-Encoding is not accepted", 400)
910
+ )
911
+ lengths = values.get(b"content-length", [])
912
+ if len(lengths) > 1:
913
+ return with_allowed_origin(
914
+ _error("invalid_request", "Duplicate Content-Length is not accepted", 400)
915
+ )
916
+ if binary_upload:
917
+ # Attachment chunks: exact octet-stream body, one Content-Range, no
918
+ # JSON parsing. The route handler and store re-validate the range.
919
+ if len(lengths) != 1:
920
+ return with_allowed_origin(
921
+ _error("invalid_request", "Exactly one Content-Length is required", 400)
922
+ )
923
+ try:
924
+ raw_length = lengths[0].decode("ascii")
925
+ body_length = int(raw_length, 10)
926
+ except (UnicodeDecodeError, ValueError):
927
+ return with_allowed_origin(
928
+ _error("invalid_request", "Content-Length is invalid", 400)
929
+ )
930
+ if raw_length != str(body_length) or not 1 <= body_length <= TEAM_ATTACHMENT_CHUNK_BYTES:
931
+ return with_allowed_origin(
932
+ _error("request_too_large", "Attachment chunk size is invalid", 413)
933
+ )
934
+ if values.get(b"content-type", []) != [b"application/octet-stream"]:
935
+ return with_allowed_origin(
936
+ _error(
937
+ "invalid_request",
938
+ "Content-Type must be application/octet-stream",
939
+ 415,
940
+ )
941
+ )
942
+ if len(values.get(b"content-range", [])) != 1:
943
+ return with_allowed_origin(
944
+ _error("invalid_request", "Exactly one Content-Range is required", 400)
945
+ )
946
+ elif request.method in {"POST", "PUT", "PATCH", "DELETE"}:
947
+ if len(lengths) != 1:
948
+ return with_allowed_origin(
949
+ _error("invalid_request", "Exactly one Content-Length is required", 400)
950
+ )
951
+ try:
952
+ raw_length = lengths[0].decode("ascii")
953
+ body_length = int(raw_length, 10)
954
+ except (UnicodeDecodeError, ValueError):
955
+ return with_allowed_origin(
956
+ _error("invalid_request", "Content-Length is invalid", 400)
957
+ )
958
+ if raw_length != str(body_length) or not 2 <= body_length <= MAX_JSON_BODY_BYTES:
959
+ return with_allowed_origin(
960
+ _error("invalid_request", "JSON body size is invalid", 413)
961
+ )
962
+ content_types = values.get(b"content-type", [])
963
+ if content_types != [b"application/json"]:
964
+ return with_allowed_origin(
965
+ _error("invalid_request", "Content-Type must be application/json", 415)
966
+ )
967
+ try:
968
+ body = await asyncio.wait_for(
969
+ request.body(), timeout=BODY_READ_TIMEOUT_SECONDS
970
+ )
971
+ except asyncio.TimeoutError:
972
+ return with_allowed_origin(
973
+ _error("request_timeout", "Request body timed out", 408)
974
+ )
975
+ if len(body) != body_length:
976
+ return with_allowed_origin(
977
+ _error("invalid_request", "Content-Length does not match body", 400)
978
+ )
979
+ try:
980
+ parsed = json.loads(body)
981
+ except (UnicodeDecodeError, json.JSONDecodeError):
982
+ return with_allowed_origin(
983
+ _error("invalid_request", "Request body must be valid JSON", 400)
984
+ )
985
+ if not isinstance(parsed, dict):
986
+ return with_allowed_origin(
987
+ _error("invalid_request", "Request body must be a JSON object", 400)
988
+ )
989
+ try:
990
+ json.dumps(parsed, ensure_ascii=False).encode("utf-8")
991
+ except UnicodeEncodeError:
992
+ return with_allowed_origin(
993
+ _error("invalid_request", "Request body contains invalid Unicode", 400)
994
+ )
995
+ return with_allowed_origin(await call_next(request))
996
+
997
+ @app.exception_handler(HubError)
998
+ async def hub_error_handler(_request: Request, exc: HubError):
999
+ return _error(exc.code, exc.message, exc.status_code)
1000
+
1001
+ @app.exception_handler(RequestValidationError)
1002
+ async def validation_error_handler(_request: Request, _exc: RequestValidationError):
1003
+ return _error("invalid_request", "Request validation failed", 422)
1004
+
1005
+ @app.exception_handler(HTTPException)
1006
+ async def http_error_handler(_request: Request, exc: HTTPException):
1007
+ if exc.status_code == 404:
1008
+ return _error("not_found", "Resource not found", 404)
1009
+ return _error("request_failed", "Request failed", exc.status_code)
1010
+
1011
+ @app.exception_handler(AuthenticationError)
1012
+ async def authentication_error_handler(_request: Request, _exc: AuthenticationError):
1013
+ return _error("authentication_required", "Authentication required", 401)
1014
+
1015
+ @app.exception_handler(AuthorizationError)
1016
+ async def authorization_error_handler(_request: Request, _exc: AuthorizationError):
1017
+ return _error("forbidden", "Operation is not permitted", 403)
1018
+
1019
+ @app.exception_handler(ValueError)
1020
+ async def value_error_handler(_request: Request, _exc: ValueError):
1021
+ return _error("invalid_request", "Request validation failed", 422)
1022
+
1023
+ @app.exception_handler(Exception)
1024
+ async def unexpected_error_handler(_request: Request, _exc: Exception):
1025
+ return _error("internal_error", "Internal server error", 500)
1026
+
1027
+ def claims_from_request(request: Request) -> AccessClaims:
1028
+ if request.scope.get(MANAGED_SERVER_SESSION_SCOPE_KEY) is True:
1029
+ return store.managed_server_claims()
1030
+ raw = request.scope.get("headers", [])
1031
+ authorization = [value for key, value in raw if key.lower() == b"authorization"]
1032
+ if len(authorization) != 1:
1033
+ raise HubError("authentication_required", "Authentication required", 401)
1034
+ try:
1035
+ value = authorization[0].decode("ascii")
1036
+ except UnicodeDecodeError as exc:
1037
+ raise HubError("authentication_required", "Authentication required", 401) from exc
1038
+ if not value.startswith("Bearer ") or " " in value[7:] or not value[7:]:
1039
+ raise HubError("authentication_required", "Authentication required", 401)
1040
+ return store.verify_access(value[7:])
1041
+
1042
+ Auth = Annotated[AccessClaims, Depends(claims_from_request)]
1043
+
1044
+ @app.get("/v1/health")
1045
+ def health(request: Request) -> dict[str, Any]:
1046
+ result = store.health()
1047
+ if request.scope.get(MANAGED_SERVER_SESSION_SCOPE_KEY) is True:
1048
+ server_session_available = False
1049
+ if result["bootstrapped"]:
1050
+ try:
1051
+ store.managed_server_claims()
1052
+ except Exception:
1053
+ server_session_available = False
1054
+ else:
1055
+ server_session_available = True
1056
+ result["server_session_available"] = server_session_available
1057
+ return result
1058
+
1059
+ @app.get("/v1/server-session")
1060
+ def server_session(request: Request) -> dict[str, Any]:
1061
+ if request.scope.get(MANAGED_SERVER_SESSION_SCOPE_KEY) is not True:
1062
+ raise HubError("not_found", "Resource not found", 404)
1063
+ return store.session_snapshot(store.managed_server_claims())
1064
+
1065
+ @app.post("/v1/bootstrap/redeem")
1066
+ def bootstrap(request: Request, body: BootstrapRequest) -> dict[str, Any]:
1067
+ proof = _exact_ascii_header(request, b"x-team-hub-bootstrap-proof")
1068
+ if proof is None:
1069
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
1070
+ transport = getattr(request.state, "team_hub_transport", None)
1071
+ if transport is None:
1072
+ transport = (
1073
+ "loopback"
1074
+ if _is_loopback_peer(request) and _has_loopback_authority(request)
1075
+ else None
1076
+ )
1077
+ if transport == "loopback":
1078
+ if _exact_ascii_header(
1079
+ request, b"x-team-hub-bootstrap-request-id"
1080
+ ) is not None:
1081
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
1082
+ return store.bootstrap(
1083
+ proof,
1084
+ body.email,
1085
+ body.display_name,
1086
+ body.device_label,
1087
+ transport="loopback",
1088
+ )
1089
+ if transport == "tailscale_serve":
1090
+ request_id = _exact_ascii_header(
1091
+ request, b"x-team-hub-bootstrap-request-id"
1092
+ )
1093
+ tailnet_login = getattr(request.state, "tailnet_login", None)
1094
+ route_hub_url = getattr(request.state, "team_hub_url", None)
1095
+ if request_id is None or tailnet_login is None or route_hub_url is None:
1096
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
1097
+ return store.bootstrap(
1098
+ proof,
1099
+ body.email,
1100
+ body.display_name,
1101
+ body.device_label,
1102
+ transport="tailscale_serve",
1103
+ request_id=request_id,
1104
+ tailnet_login=tailnet_login,
1105
+ hub_url=route_hub_url,
1106
+ )
1107
+ if transport == "direct_ip":
1108
+ request_id = _exact_ascii_header(
1109
+ request, b"x-team-hub-bootstrap-request-id"
1110
+ )
1111
+ route_hub_url = getattr(request.state, "team_hub_url", None)
1112
+ if request_id is None or route_hub_url is None:
1113
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
1114
+ return store.bootstrap(
1115
+ proof,
1116
+ body.email,
1117
+ body.display_name,
1118
+ body.device_label,
1119
+ transport="direct_ip",
1120
+ request_id=request_id,
1121
+ tailnet_login=body.email,
1122
+ hub_url=route_hub_url,
1123
+ )
1124
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
1125
+
1126
+ @app.post("/v1/owner-recovery/redeem")
1127
+ def owner_recovery(request: Request, body: RedeemRecoveryRequest) -> dict[str, Any]:
1128
+ if not _is_loopback_peer(request) or not _has_loopback_authority(request):
1129
+ raise HubError("recovery_unavailable", "Owner recovery is unavailable", 403)
1130
+ proof = _exact_ascii_header(request, b"x-team-hub-owner-recovery-proof")
1131
+ if proof is None:
1132
+ raise HubError("recovery_unavailable", "Owner recovery is unavailable", 403)
1133
+ return store.redeem_owner_recovery(proof, body.device_label)
1134
+
1135
+ @app.post("/v1/device-recovery/redeem")
1136
+ def device_recovery(request: Request, body: RedeemRecoveryRequest) -> dict[str, Any]:
1137
+ classified = getattr(request.state, "team_hub_transport", None)
1138
+ loopback_peer = _is_loopback_peer(request)
1139
+ if classified not in {"loopback", "tailscale_serve", "direct_ip"} and (
1140
+ (loopback_peer and not _has_loopback_authority(request))
1141
+ or (not loopback_peer and request.url.scheme != "https")
1142
+ ):
1143
+ raise HubError("recovery_unavailable", "Device recovery is unavailable", 403)
1144
+ proof = _exact_ascii_header(request, b"x-team-hub-device-recovery-proof")
1145
+ if proof is None:
1146
+ raise HubError("recovery_unavailable", "Device recovery is unavailable", 403)
1147
+ return store.redeem_device_recovery(proof, body.device_label)
1148
+
1149
+ @app.post("/v1/sessions/refresh")
1150
+ def refresh(body: RefreshRequest) -> dict[str, Any]:
1151
+ return store.refresh(body.refresh_token)
1152
+
1153
+ @app.post("/v1/sessions/revoke")
1154
+ def revoke_session(body: RevokeSessionRequest, claims: Auth) -> dict[str, Any]:
1155
+ return store.revoke_session(claims, body.refresh_token)
1156
+
1157
+ @app.get("/v1/sessions")
1158
+ def device_sessions(
1159
+ claims: Auth,
1160
+ limit: Annotated[int, Query(ge=1, le=MAX_HUMAN_ADMIN_PAGE_ITEMS)] = 50,
1161
+ cursor: Annotated[str | None, Query(max_length=512)] = None,
1162
+ ) -> dict[str, Any]:
1163
+ return store.list_device_sessions_page(
1164
+ claims,
1165
+ limit=limit,
1166
+ cursor=cursor,
1167
+ )
1168
+
1169
+ @app.post("/v1/sessions/{session_id}/revoke")
1170
+ def revoke_device_session(session_id: str, claims: Auth) -> dict[str, Any]:
1171
+ return store.revoke_device_session(claims, session_id)
1172
+
1173
+ @app.get("/v1/session")
1174
+ def session(claims: Auth) -> dict[str, Any]:
1175
+ return store.session_snapshot(claims)
1176
+
1177
+ @app.get("/v1/teams")
1178
+ def teams(claims: Auth) -> dict[str, Any]:
1179
+ return store.list_teams(claims)
1180
+
1181
+ @app.get("/v1/teams/{team_id}")
1182
+ def team(team_id: str, claims: Auth) -> dict[str, Any]:
1183
+ return store.get_team(claims, team_id)
1184
+
1185
+ @app.get("/v1/teams/{team_id}/members")
1186
+ def members(
1187
+ team_id: str,
1188
+ claims: Auth,
1189
+ limit: Annotated[int, Query(ge=1, le=MAX_HUMAN_ADMIN_PAGE_ITEMS)] = 50,
1190
+ cursor: Annotated[str | None, Query(max_length=512)] = None,
1191
+ ) -> dict[str, Any]:
1192
+ return store.list_members(claims, team_id, limit=limit, cursor=cursor)
1193
+
1194
+ @app.get("/v1/teams/{team_id}/members/{principal_id}")
1195
+ def member(
1196
+ team_id: str,
1197
+ principal_id: str,
1198
+ claims: Auth,
1199
+ ) -> dict[str, Any]:
1200
+ return store.get_member(claims, team_id, principal_id)
1201
+
1202
+ @app.patch("/v1/teams/{team_id}/members/{principal_id}")
1203
+ def update_member(
1204
+ team_id: str,
1205
+ principal_id: str,
1206
+ body: MembershipUpdateRequest,
1207
+ claims: Auth,
1208
+ ) -> dict[str, Any]:
1209
+ return store.update_human_membership(
1210
+ claims,
1211
+ team_id,
1212
+ principal_id,
1213
+ role=body.role,
1214
+ status=body.status,
1215
+ )
1216
+
1217
+ @app.post("/v1/teams/{team_id}/invitations")
1218
+ def invite(team_id: str, body: InviteRequest, claims: Auth) -> dict[str, Any]:
1219
+ return store.issue_invite(
1220
+ claims, team_id, body.invitee_email, body.role, body.ttl_seconds
1221
+ )
1222
+
1223
+ @app.get("/v1/teams/{team_id}/invitations")
1224
+ def pending_invitations(
1225
+ team_id: str,
1226
+ claims: Auth,
1227
+ limit: Annotated[int, Query(ge=1, le=MAX_HUMAN_ADMIN_PAGE_ITEMS)] = 50,
1228
+ cursor: Annotated[str | None, Query(max_length=512)] = None,
1229
+ ) -> dict[str, Any]:
1230
+ return store.list_pending_invitations(
1231
+ claims,
1232
+ team_id,
1233
+ limit=limit,
1234
+ cursor=cursor,
1235
+ )
1236
+
1237
+ @app.post("/v1/teams/{team_id}/invitations/{invitation_id}/revoke")
1238
+ def revoke_invitation(
1239
+ team_id: str,
1240
+ invitation_id: str,
1241
+ claims: Auth,
1242
+ ) -> dict[str, Any]:
1243
+ return store.revoke_invitation(claims, team_id, invitation_id)
1244
+
1245
+ @app.post("/v1/invitations/redeem")
1246
+ def redeem_invite(body: RedeemInviteRequest) -> dict[str, Any]:
1247
+ return store.redeem_invite(
1248
+ body.token, body.email, body.display_name, body.device_label
1249
+ )
1250
+
1251
+ @app.post("/v1/invitations/accept")
1252
+ def accept_invite(body: AcceptInviteRequest, claims: Auth) -> dict[str, Any]:
1253
+ return store.accept_invite(claims, body.token)
1254
+
1255
+ @app.post("/v1/teams/{team_id}/node-enrollments")
1256
+ def node_grant(team_id: str, body: NodeGrantRequest, claims: Auth) -> dict[str, Any]:
1257
+ return store.issue_node_grant(
1258
+ claims,
1259
+ team_id,
1260
+ body.server_identity,
1261
+ body.display_name,
1262
+ body.public_key,
1263
+ body.ttl_seconds,
1264
+ )
1265
+
1266
+ @app.post("/v1/node-enrollments/challenge")
1267
+ def node_challenge(body: NodeChallengeRequest) -> dict[str, Any]:
1268
+ return store.node_challenge(
1269
+ body.token, body.server_identity, body.display_name, body.public_key
1270
+ )
1271
+
1272
+ @app.post("/v1/node-enrollments/redeem")
1273
+ def node_redeem(body: NodeRedeemRequest) -> dict[str, Any]:
1274
+ return store.redeem_node_challenge(body.challenge_id, body.signature)
1275
+
1276
+ @app.get("/v1/teams/{team_id}/nodes")
1277
+ def nodes(team_id: str, claims: Auth) -> dict[str, Any]:
1278
+ return store.list_nodes(claims, team_id)
1279
+
1280
+ @app.get("/v1/teams/{team_id}/secure-peers")
1281
+ def secure_peers(team_id: str, claims: Auth) -> dict[str, Any]:
1282
+ store.require_team_admin(claims, team_id)
1283
+ if secure_peer_manager is None:
1284
+ raise HubError(
1285
+ "secure_peer_unavailable",
1286
+ "Secure peer pairing is unavailable",
1287
+ 503,
1288
+ )
1289
+ return secure_peer_manager.list_peers(team_id=team_id)
1290
+
1291
+ @app.post("/v1/teams/{team_id}/secure-peers/{peer_id}/revoke")
1292
+ def revoke_secure_peer(
1293
+ team_id: str,
1294
+ peer_id: str,
1295
+ body: SecurePeerRevocationRequest,
1296
+ claims: Auth,
1297
+ ) -> dict[str, Any]:
1298
+ store.require_team_admin(claims, team_id)
1299
+ if secure_peer_manager is None:
1300
+ raise HubError(
1301
+ "secure_peer_unavailable",
1302
+ "Secure peer pairing is unavailable",
1303
+ 503,
1304
+ )
1305
+ return secure_peer_manager.revoke_peer(
1306
+ peer_id=peer_id,
1307
+ team_id=team_id,
1308
+ revoked_by=claims.principal_id,
1309
+ expected_certificate_fingerprint=(
1310
+ body.expected_certificate_fingerprint
1311
+ ),
1312
+ idempotency_key=body.idempotency_key,
1313
+ )
1314
+
1315
+ @app.get("/v1/teams/{team_id}/network")
1316
+ def network(
1317
+ team_id: str,
1318
+ claims: Auth,
1319
+ after_server_id: Annotated[
1320
+ str | None, Query(min_length=8, max_length=240)
1321
+ ] = None,
1322
+ limit: Annotated[int, Query(ge=1, le=MAX_NETWORK_PAGE_ITEMS)] = 50,
1323
+ ) -> dict[str, Any]:
1324
+ return store.get_network(
1325
+ claims,
1326
+ team_id,
1327
+ after_server_id=after_server_id,
1328
+ limit=limit,
1329
+ )
1330
+
1331
+ @app.get("/v1/teams/{team_id}/network/servers/{server_id}")
1332
+ def network_server(
1333
+ team_id: str,
1334
+ server_id: str,
1335
+ claims: Auth,
1336
+ ) -> dict[str, Any]:
1337
+ return store.get_network_server(claims, team_id, server_id)
1338
+
1339
+ @app.post("/v1/teams/{team_id}/network/agents")
1340
+ def register_network_agent(
1341
+ team_id: str,
1342
+ body: NetworkAgentRequest,
1343
+ claims: Auth,
1344
+ ) -> dict[str, Any]:
1345
+ return store.register_network_agent(claims, team_id, body.model_dump())
1346
+
1347
+ @app.get("/v1/teams/{team_id}/network/bulletin")
1348
+ def network_bulletin(
1349
+ team_id: str,
1350
+ claims: Auth,
1351
+ after_sequence: Annotated[
1352
+ int, Query(ge=0, le=9_223_372_036_854_775_807)
1353
+ ] = 0,
1354
+ limit: Annotated[int, Query(ge=1, le=MAX_NETWORK_PAGE_ITEMS)] = 50,
1355
+ ) -> dict[str, Any]:
1356
+ return store.list_network_bulletin(
1357
+ claims,
1358
+ team_id,
1359
+ after_sequence=after_sequence,
1360
+ limit=limit,
1361
+ )
1362
+
1363
+ @app.post("/v1/teams/{team_id}/network/bulletin")
1364
+ def create_network_bulletin_post(
1365
+ team_id: str,
1366
+ body: NetworkBulletinRequest,
1367
+ claims: Auth,
1368
+ ) -> dict[str, Any]:
1369
+ return store.create_network_bulletin_post(
1370
+ claims, team_id, body.model_dump()
1371
+ )
1372
+
1373
+ @app.delete("/v1/teams/{team_id}/network/bulletin/{post_id}")
1374
+ def delete_network_bulletin_post(
1375
+ team_id: str,
1376
+ post_id: str,
1377
+ body: NetworkDeleteRequest,
1378
+ claims: Auth,
1379
+ ) -> dict[str, Any]:
1380
+ return store.delete_network_bulletin_post(
1381
+ claims,
1382
+ team_id,
1383
+ post_id,
1384
+ body.model_dump(),
1385
+ )
1386
+
1387
+ @app.get("/v1/teams/{team_id}/network/mailbox")
1388
+ def network_mailbox(
1389
+ team_id: str,
1390
+ claims: Auth,
1391
+ address_kind: Annotated[Literal["human", "server", "agent"], Query()],
1392
+ address_id: Annotated[str, Query(min_length=1, max_length=240)],
1393
+ after_sequence: Annotated[
1394
+ int, Query(ge=0, le=9_223_372_036_854_775_807)
1395
+ ] = 0,
1396
+ limit: Annotated[int, Query(ge=1, le=MAX_NETWORK_PAGE_ITEMS)] = 50,
1397
+ ) -> dict[str, Any]:
1398
+ return store.list_network_mailbox(
1399
+ claims,
1400
+ team_id,
1401
+ address_kind=address_kind,
1402
+ address_id=address_id,
1403
+ after_sequence=after_sequence,
1404
+ limit=limit,
1405
+ )
1406
+
1407
+ @app.post("/v1/teams/{team_id}/network/mailbox")
1408
+ def send_network_mailbox_item(
1409
+ team_id: str,
1410
+ body: NetworkMailboxRequest,
1411
+ claims: Auth,
1412
+ ) -> dict[str, Any]:
1413
+ return store.create_network_mailbox_item(
1414
+ claims, team_id, body.model_dump()
1415
+ )
1416
+
1417
+ @app.get("/v1/teams/{team_id}/network/items/{item_id}")
1418
+ def network_item(
1419
+ team_id: str,
1420
+ item_id: str,
1421
+ claims: Auth,
1422
+ ) -> dict[str, Any]:
1423
+ return store.get_network_item(claims, team_id, item_id)
1424
+
1425
+ @app.post("/v1/teams/{team_id}/network/deliveries/{delivery_id}/receipts")
1426
+ def network_delivery_receipt(
1427
+ team_id: str,
1428
+ delivery_id: str,
1429
+ body: NetworkReceiptRequest,
1430
+ claims: Auth,
1431
+ ) -> dict[str, Any]:
1432
+ return store.record_network_delivery_receipt(
1433
+ claims, team_id, delivery_id, body.model_dump()
1434
+ )
1435
+
1436
+ @app.post("/v1/teams/{team_id}/network/requests")
1437
+ def create_network_request(
1438
+ team_id: str,
1439
+ body: NetworkPassiveRequest,
1440
+ claims: Auth,
1441
+ ) -> dict[str, Any]:
1442
+ return store.create_network_request(claims, team_id, body.model_dump())
1443
+
1444
+ @app.get("/v1/teams/{team_id}/network/requests/{request_id}")
1445
+ def network_request(
1446
+ team_id: str,
1447
+ request_id: str,
1448
+ claims: Auth,
1449
+ ) -> dict[str, Any]:
1450
+ return store.get_network_request(claims, team_id, request_id)
1451
+
1452
+ @app.post("/v1/teams/{team_id}/network/requests/{request_id}/replies")
1453
+ def create_network_request_reply(
1454
+ team_id: str,
1455
+ request_id: str,
1456
+ body: NetworkReplyRequest,
1457
+ claims: Auth,
1458
+ ) -> dict[str, Any]:
1459
+ return store.create_network_request_reply(
1460
+ claims, team_id, request_id, body.model_dump()
1461
+ )
1462
+
1463
+ @app.get("/v1/teams/{team_id}/network/messages")
1464
+ def team_messages(
1465
+ team_id: str,
1466
+ claims: Auth,
1467
+ box: Annotated[Literal["inbox", "feed", "sent"], Query()] = "inbox",
1468
+ address_kind: Annotated[Literal["server", "human"] | None, Query()] = None,
1469
+ address_id: Annotated[str | None, Query(min_length=1, max_length=240)] = None,
1470
+ unread: Annotated[bool, Query()] = False,
1471
+ from_kind: Annotated[Literal["server", "human"] | None, Query()] = None,
1472
+ from_id: Annotated[str | None, Query(min_length=1, max_length=240)] = None,
1473
+ since: Annotated[str | None, Query(min_length=1, max_length=40)] = None,
1474
+ after_sequence: Annotated[
1475
+ int, Query(ge=0, le=9_223_372_036_854_775_807)
1476
+ ] = 0,
1477
+ limit: Annotated[int, Query(ge=1, le=MAX_NETWORK_PAGE_ITEMS)] = 50,
1478
+ include_revision: Annotated[bool, Query()] = False,
1479
+ include_mail_subject: Annotated[bool, Query()] = False,
1480
+ include_mailbox_state: Annotated[bool, Query()] = False,
1481
+ include_mailbox_coverage: Annotated[bool, Query()] = False,
1482
+ after_arrival_id: Annotated[str | None, Query(pattern=r"^tmsg_[0-9a-f]{32}$")] = None,
1483
+ q: Annotated[str | None, Query(min_length=1, max_length=200)] = None,
1484
+ ) -> dict[str, Any]:
1485
+ return store.list_team_messages(
1486
+ claims,
1487
+ team_id,
1488
+ box=box,
1489
+ address_kind=address_kind,
1490
+ address_id=address_id,
1491
+ unread=unread,
1492
+ from_kind=from_kind,
1493
+ from_id=from_id,
1494
+ since=since,
1495
+ after_sequence=after_sequence,
1496
+ limit=limit,
1497
+ include_revision=include_revision,
1498
+ include_mail_subject=include_mail_subject,
1499
+ include_mailbox_state=include_mailbox_state,
1500
+ include_mailbox_coverage=include_mailbox_coverage,
1501
+ after_arrival_id=after_arrival_id,
1502
+ q=q,
1503
+ )
1504
+
1505
+ @app.get("/v1/teams/{team_id}/network/deletions")
1506
+ def network_content_deletions(
1507
+ team_id: str,
1508
+ claims: Auth,
1509
+ after_sequence: Annotated[
1510
+ int, Query(ge=0, le=9_223_372_036_854_775_807)
1511
+ ] = 0,
1512
+ limit: Annotated[int, Query(ge=1, le=MAX_NETWORK_PAGE_ITEMS)] = 50,
1513
+ ) -> dict[str, Any]:
1514
+ return store.list_network_content_deletions(
1515
+ claims,
1516
+ team_id,
1517
+ after_sequence=after_sequence,
1518
+ limit=limit,
1519
+ )
1520
+
1521
+ @app.post("/v1/teams/{team_id}/network/messages")
1522
+ def create_team_message(
1523
+ team_id: str,
1524
+ body: TeamMessageRequest,
1525
+ claims: Auth,
1526
+ ) -> dict[str, Any]:
1527
+ return store.create_team_message(claims, team_id, body.model_dump())
1528
+
1529
+ @app.get("/v1/teams/{team_id}/network/messages/{message_id}")
1530
+ def team_message(
1531
+ team_id: str,
1532
+ message_id: str,
1533
+ claims: Auth,
1534
+ include_revision: Annotated[bool, Query()] = False,
1535
+ include_mail_subject: Annotated[bool, Query()] = False,
1536
+ include_mailbox_state: Annotated[bool, Query()] = False,
1537
+ ) -> dict[str, Any]:
1538
+ return store.get_team_message(
1539
+ claims,
1540
+ team_id,
1541
+ message_id,
1542
+ include_revision=include_revision,
1543
+ include_mail_subject=include_mail_subject,
1544
+ include_mailbox_state=include_mailbox_state,
1545
+ )
1546
+
1547
+ @app.get("/v1/teams/{team_id}/network/messages/{message_id}/thread")
1548
+ def team_message_thread(
1549
+ team_id: str,
1550
+ message_id: str,
1551
+ claims: Auth,
1552
+ after_sequence: Annotated[int, Query(ge=0, le=9_223_372_036_854_775_807)] = 0,
1553
+ limit: Annotated[int, Query(ge=1, le=MAX_TEAM_MAIL_THREAD_PAGE_ITEMS)] = 25,
1554
+ ) -> dict[str, Any]:
1555
+ return store.get_team_message_thread(claims, team_id, message_id,
1556
+ after_sequence=after_sequence, limit=limit)
1557
+
1558
+ @app.get("/v1/teams/{team_id}/network/messages/{message_id}/revisions")
1559
+ def team_message_revisions(
1560
+ team_id: str,
1561
+ message_id: str,
1562
+ claims: Auth,
1563
+ version: Annotated[int | None, Query(ge=1, le=200)] = None,
1564
+ ) -> dict[str, Any]:
1565
+ return store.list_team_message_revisions(claims, team_id, message_id, version=version)
1566
+
1567
+ @app.post("/v1/teams/{team_id}/network/messages/{message_id}/mailbox-state")
1568
+ def team_message_mailbox_state(
1569
+ team_id: str, message_id: str, body: TeamMailboxStateRequest, claims: Auth,
1570
+ ) -> dict[str, Any]:
1571
+ return store.set_team_message_mailbox_state(claims, team_id, message_id, body.model_dump())
1572
+
1573
+ @app.post("/v1/teams/{team_id}/network/messages/{message_id}/dismissals")
1574
+ def dismiss_team_message(
1575
+ team_id: str,
1576
+ message_id: str,
1577
+ body: TeamMessageDismissalRequest,
1578
+ claims: Auth,
1579
+ ) -> dict[str, Any]:
1580
+ return store.dismiss_team_message(claims, team_id, message_id, body.model_dump())
1581
+
1582
+ @app.post("/v1/teams/{team_id}/network/messages/{message_id}/revisions")
1583
+ def revise_team_message(
1584
+ team_id: str,
1585
+ message_id: str,
1586
+ body: TeamMessageRevisionRequest,
1587
+ claims: Auth,
1588
+ include_mail_subject: Annotated[bool, Query()] = False,
1589
+ ) -> dict[str, Any]:
1590
+ return store.revise_team_message(
1591
+ claims,
1592
+ team_id,
1593
+ message_id,
1594
+ body.model_dump(),
1595
+ include_mail_subject=include_mail_subject,
1596
+ )
1597
+
1598
+ @app.delete("/v1/teams/{team_id}/network/messages/{message_id}")
1599
+ def delete_team_message(
1600
+ team_id: str,
1601
+ message_id: str,
1602
+ body: NetworkDeleteRequest,
1603
+ claims: Auth,
1604
+ ) -> dict[str, Any]:
1605
+ return store.delete_team_message(
1606
+ claims,
1607
+ team_id,
1608
+ message_id,
1609
+ body.model_dump(),
1610
+ )
1611
+
1612
+ @app.post("/v1/teams/{team_id}/network/messages/{message_id}/receipts")
1613
+ def team_message_receipt(
1614
+ team_id: str,
1615
+ message_id: str,
1616
+ body: TeamReceiptRequest,
1617
+ claims: Auth,
1618
+ ) -> dict[str, Any]:
1619
+ return store.record_team_message_receipt(
1620
+ claims, team_id, message_id, body.model_dump()
1621
+ )
1622
+
1623
+ @app.post("/v1/teams/{team_id}/network/attachments")
1624
+ def declare_team_attachment(
1625
+ team_id: str,
1626
+ body: TeamAttachmentDeclareRequest,
1627
+ claims: Auth,
1628
+ ) -> dict[str, Any]:
1629
+ return store.declare_team_attachment(claims, team_id, body.model_dump())
1630
+
1631
+ @app.get("/v1/teams/{team_id}/network/attachments/{attachment_id}")
1632
+ def team_attachment(
1633
+ team_id: str, attachment_id: str, claims: Auth
1634
+ ) -> dict[str, Any]:
1635
+ return store.get_team_attachment(claims, team_id, attachment_id)
1636
+
1637
+ @app.put("/v1/teams/{team_id}/network/attachments/{attachment_id}/content")
1638
+ async def upload_team_attachment_chunk(
1639
+ team_id: str,
1640
+ attachment_id: str,
1641
+ request: Request,
1642
+ claims: Auth,
1643
+ ) -> dict[str, Any]:
1644
+ match = _CONTENT_RANGE_RE.fullmatch(request.headers.get("content-range", "").strip())
1645
+ if match is None:
1646
+ raise HubError("invalid_request", "Content-Range is required", 422)
1647
+ start, end, total = int(match["start"]), int(match["end"]), int(match["total"])
1648
+ if end < start or total < 1 or end >= total:
1649
+ raise HubError("invalid_request", "Content-Range is invalid", 422)
1650
+ data = await asyncio.wait_for(request.body(), timeout=BODY_READ_TIMEOUT_SECONDS * 6)
1651
+ if len(data) != end - start + 1:
1652
+ raise HubError("invalid_request", "Chunk length does not match Content-Range", 422)
1653
+ return store.write_team_attachment_chunk(
1654
+ claims, team_id, attachment_id, offset=start, total=total, data=data
1655
+ )
1656
+
1657
+ @app.api_route(
1658
+ "/v1/teams/{team_id}/network/attachments/{attachment_id}/content",
1659
+ methods=["GET", "HEAD"],
1660
+ )
1661
+ def download_team_attachment(
1662
+ team_id: str,
1663
+ attachment_id: str,
1664
+ request: Request,
1665
+ claims: Auth,
1666
+ ) -> Response:
1667
+ attachment, path = store.open_team_attachment(claims, team_id, attachment_id)
1668
+ return attachment_content_response(request, attachment, path)
1669
+
1670
+ @app.get("/v1/teams/{team_id}/network/skills")
1671
+ def team_skills(
1672
+ team_id: str,
1673
+ claims: Auth,
1674
+ include_archived: Annotated[bool, Query()] = False,
1675
+ slug: Annotated[str | None, Query(min_length=1, max_length=64)] = None,
1676
+ ) -> dict[str, Any]:
1677
+ return store.list_team_skills(
1678
+ claims, team_id, include_archived=include_archived, slug=slug
1679
+ )
1680
+
1681
+ @app.get("/v1/teams/{team_id}/network/skills/{skill_id}")
1682
+ def team_skill(team_id: str, skill_id: str, claims: Auth) -> dict[str, Any]:
1683
+ return store.get_team_skill(claims, team_id, skill_id)
1684
+
1685
+ @app.get("/v1/teams/{team_id}/network/skills/{skill_id}/versions")
1686
+ def team_skill_versions(team_id: str, skill_id: str, claims: Auth) -> dict[str, Any]:
1687
+ return store.list_team_skill_versions(claims, team_id, skill_id)
1688
+
1689
+ @app.get("/v1/teams/{team_id}/network/skills/{skill_id}/versions/{version}")
1690
+ def team_skill_version(
1691
+ team_id: str, skill_id: str, version: int, claims: Auth
1692
+ ) -> dict[str, Any]:
1693
+ return store.get_team_skill_version(claims, team_id, skill_id, version)
1694
+
1695
+ @app.post("/v1/teams/{team_id}/network/skills/{skill_id}/pin")
1696
+ def pin_team_skill(
1697
+ team_id: str, skill_id: str, body: TeamSkillPinRequest, claims: Auth
1698
+ ) -> dict[str, Any]:
1699
+ return store.set_team_skill_pinned(claims, team_id, skill_id, body.model_dump())
1700
+
1701
+ @app.post("/v1/teams/{team_id}/network/skills/{skill_id}/archive")
1702
+ def archive_team_skill(
1703
+ team_id: str, skill_id: str, body: TeamSkillArchiveRequest, claims: Auth
1704
+ ) -> dict[str, Any]:
1705
+ return store.set_team_skill_archived(claims, team_id, skill_id, body.model_dump())
1706
+
1707
+ @app.get("/v1/teams/{team_id}/channels")
1708
+ def channels(team_id: str, claims: Auth) -> dict[str, Any]:
1709
+ return store.list_channels(claims, team_id)
1710
+
1711
+ @app.post("/v1/teams/{team_id}/channels")
1712
+ def create_channel(team_id: str, body: ChannelRequest, claims: Auth) -> dict[str, Any]:
1713
+ return store.create_channel(claims, team_id, body.model_dump())
1714
+
1715
+ @app.get("/v1/channels/{channel_id}/messages")
1716
+ def messages(
1717
+ channel_id: str,
1718
+ claims: Auth,
1719
+ limit: Annotated[int, Query(ge=1, le=100)] = 50,
1720
+ before_sequence: Annotated[int | None, Query(ge=1)] = None,
1721
+ ) -> dict[str, Any]:
1722
+ return store.list_messages(claims, channel_id, limit, before_sequence)
1723
+
1724
+ @app.post("/v1/channels/{channel_id}/messages")
1725
+ def create_message(channel_id: str, body: MessageRequest, claims: Auth) -> dict[str, Any]:
1726
+ return store.create_message(claims, channel_id, body.model_dump())
1727
+
1728
+ @app.post("/v1/dispatches")
1729
+ def dispatch_unavailable(claims: Auth) -> dict[str, Any]:
1730
+ store.session_snapshot(claims)
1731
+ raise HubError(
1732
+ "dispatch_unavailable",
1733
+ "Dispatch requires a scoped capability and node connector",
1734
+ 501,
1735
+ )
1736
+
1737
+ return app