@agentsdock/server 1.0.7-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,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
|