@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,710 @@
1
+ """Fail-closed Team Hub bootstrap, invitation, and node-enrollment primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ import base64
8
+ import binascii
9
+ import hashlib
10
+ import hmac
11
+ import re
12
+ import secrets
13
+ import sqlite3
14
+ import struct
15
+ import time
16
+ from typing import Iterator
17
+
18
+
19
+ INVITATION_MAX_TTL_SECONDS = 24 * 60 * 60
20
+ NODE_ENROLLMENT_MAX_TTL_SECONDS = 15 * 60
21
+ MIN_TTL_SECONDS = 30
22
+ MANAGED_SERVER_PRINCIPAL_ID = "service_managed_server"
23
+ MANAGED_SERVER_SERVICE_IDENTIFIER = "agentsdock.team-hub.managed-server"
24
+ EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
25
+ IDENTITY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{7,239}$")
26
+
27
+
28
+ class AuthenticationError(RuntimeError):
29
+ """Raised for an invalid, expired, revoked, or already-used credential."""
30
+
31
+
32
+ class AuthorizationError(RuntimeError):
33
+ """Raised when a principal lacks the required active team role."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class IssuedSecret:
38
+ """A one-time secret returned only at issuance; only its hash is persisted."""
39
+
40
+ id: str
41
+ token: str = field(repr=False, compare=False)
42
+ expires_at: int
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class BootstrapResult:
47
+ human_principal_id: str
48
+ team_id: str
49
+ membership_id: str
50
+ created: bool
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class EnrollmentResult:
55
+ team_id: str
56
+ node_id: str
57
+ principal_id: str
58
+ credential_id: str
59
+
60
+
61
+ def _id(prefix: str) -> str:
62
+ return f"{prefix}_{secrets.token_hex(16)}"
63
+
64
+
65
+ def _timestamp(now: int | None) -> int:
66
+ value = int(time.time()) if now is None else int(now)
67
+ if value < 0:
68
+ raise ValueError("timestamp must be non-negative")
69
+ return value
70
+
71
+
72
+ def _bounded_text(value: str, field: str, minimum: int, maximum: int) -> str:
73
+ normalized = value.strip()
74
+ if not minimum <= len(normalized) <= maximum:
75
+ raise ValueError(f"{field} must be between {minimum} and {maximum} characters")
76
+ return normalized
77
+
78
+
79
+ def _email(value: str) -> str:
80
+ normalized = value.strip().lower()
81
+ if len(normalized) > 320 or EMAIL_PATTERN.fullmatch(normalized) is None:
82
+ raise ValueError("email is invalid")
83
+ return normalized
84
+
85
+
86
+ def _identity(value: str) -> str:
87
+ normalized = value.strip()
88
+ if IDENTITY_PATTERN.fullmatch(normalized) is None:
89
+ raise ValueError("server identity is invalid")
90
+ return normalized
91
+
92
+
93
+ def _ttl(value: int, maximum: int) -> int:
94
+ ttl = int(value)
95
+ if not MIN_TTL_SECONDS <= ttl <= maximum:
96
+ raise ValueError(f"ttl_seconds must be between {MIN_TTL_SECONDS} and {maximum}")
97
+ return ttl
98
+
99
+
100
+ def _token_digest(token: str) -> bytes:
101
+ if not 16 <= len(token) <= 512:
102
+ # Still hash a fixed value before failing so malformed tokens do not take a
103
+ # uniquely cheap path at the credential comparison boundary.
104
+ hashlib.sha256(b"invalid-team-hub-token").digest()
105
+ raise AuthenticationError("credential is invalid or unavailable")
106
+ return hashlib.sha256(token.encode("utf-8")).digest()
107
+
108
+
109
+ def _new_secret() -> tuple[str, bytes]:
110
+ token = secrets.token_urlsafe(32)
111
+ return token, hashlib.sha256(token.encode("ascii")).digest()
112
+
113
+
114
+ def _canonical_ed25519_public_key(public_material: str) -> tuple[str, bytes]:
115
+ """Parse one OpenSSH Ed25519 public key and remove its optional comment."""
116
+
117
+ value = _bounded_text(public_material, "public_material", 32, 16384)
118
+ if "PRIVATE KEY" in value.upper():
119
+ raise ValueError("private key material is not accepted")
120
+ parts = value.split()
121
+ if len(parts) < 2 or parts[0] != "ssh-ed25519":
122
+ raise ValueError("ed25519 credential must be an OpenSSH public key")
123
+ try:
124
+ wire = base64.b64decode(parts[1].encode("ascii"), validate=True)
125
+ except (UnicodeEncodeError, binascii.Error) as exc:
126
+ raise ValueError("ed25519 public key encoding is invalid") from exc
127
+
128
+ def read_field(offset: int) -> tuple[bytes, int]:
129
+ if len(wire) - offset < 4:
130
+ raise ValueError("ed25519 public key encoding is truncated")
131
+ length = struct.unpack(">I", wire[offset : offset + 4])[0]
132
+ start = offset + 4
133
+ end = start + length
134
+ if end > len(wire):
135
+ raise ValueError("ed25519 public key encoding is truncated")
136
+ return wire[start:end], end
137
+
138
+ algorithm, offset = read_field(0)
139
+ key_bytes, offset = read_field(offset)
140
+ if algorithm != b"ssh-ed25519" or len(key_bytes) != 32 or offset != len(wire):
141
+ raise ValueError("ed25519 public key encoding is invalid")
142
+ encoded = base64.b64encode(wire).decode("ascii")
143
+ return f"ssh-ed25519 {encoded}", hashlib.sha256(wire).digest()
144
+
145
+
146
+ @contextmanager
147
+ def _write_transaction(connection: sqlite3.Connection) -> Iterator[None]:
148
+ """Use an immediate transaction, nesting safely inside caller transactions."""
149
+
150
+ if connection.in_transaction:
151
+ savepoint = f"team_hub_{secrets.token_hex(8)}"
152
+ connection.execute(f"SAVEPOINT {savepoint}")
153
+ try:
154
+ yield
155
+ connection.execute(f"RELEASE SAVEPOINT {savepoint}")
156
+ except BaseException:
157
+ connection.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
158
+ connection.execute(f"RELEASE SAVEPOINT {savepoint}")
159
+ raise
160
+ return
161
+
162
+ connection.execute("BEGIN IMMEDIATE")
163
+ try:
164
+ yield
165
+ connection.execute("COMMIT")
166
+ except BaseException:
167
+ connection.execute("ROLLBACK")
168
+ raise
169
+
170
+
171
+ def _require_team_role(
172
+ connection: sqlite3.Connection,
173
+ team_id: str,
174
+ principal_id: str,
175
+ allowed_roles: tuple[str, ...],
176
+ ) -> sqlite3.Row:
177
+ row = connection.execute(
178
+ """
179
+ SELECT m.id, m.role
180
+ FROM memberships AS m
181
+ JOIN principals AS p ON p.id = m.principal_id
182
+ WHERE m.team_id = ?
183
+ AND m.principal_id = ?
184
+ AND m.status = 'active'
185
+ AND p.status = 'active'
186
+ """,
187
+ (team_id, principal_id),
188
+ ).fetchone()
189
+ if row is None or row["role"] not in allowed_roles:
190
+ raise AuthorizationError("active team role does not permit this operation")
191
+ return row
192
+
193
+
194
+ def bootstrap_personal_team(
195
+ connection: sqlite3.Connection,
196
+ email: str,
197
+ display_name: str,
198
+ *,
199
+ now: int | None = None,
200
+ ) -> BootstrapResult:
201
+ """Idempotently create one human principal and one personal team."""
202
+
203
+ email_normalized = _email(email)
204
+ name = _bounded_text(display_name, "display_name", 1, 160)
205
+ timestamp = _timestamp(now)
206
+ with _write_transaction(connection):
207
+ existing = connection.execute(
208
+ """
209
+ SELECT h.principal_id, t.id AS team_id, m.id AS membership_id
210
+ FROM human_accounts AS h
211
+ JOIN principals AS p ON p.id = h.principal_id
212
+ JOIN teams AS t ON t.personal_owner_principal_id = h.principal_id
213
+ JOIN memberships AS m
214
+ ON m.team_id = t.id AND m.principal_id = h.principal_id
215
+ WHERE h.email_normalized = ?
216
+ AND t.kind = 'personal'
217
+ AND m.role = 'owner'
218
+ AND m.status = 'active'
219
+ AND p.status = 'active'
220
+ """,
221
+ (email_normalized,),
222
+ ).fetchone()
223
+ if existing is not None:
224
+ return BootstrapResult(
225
+ human_principal_id=existing["principal_id"],
226
+ team_id=existing["team_id"],
227
+ membership_id=existing["membership_id"],
228
+ created=False,
229
+ )
230
+
231
+ orphan = connection.execute(
232
+ "SELECT principal_id FROM human_accounts WHERE email_normalized = ?",
233
+ (email_normalized,),
234
+ ).fetchone()
235
+ if orphan is not None:
236
+ raise AuthenticationError("existing account has no valid personal team")
237
+
238
+ principal_id = _id("human")
239
+ team_id = _id("team")
240
+ membership_id = _id("membership")
241
+ team_display_name = f"{name[:153]}'s Team"
242
+ connection.execute(
243
+ """
244
+ INSERT INTO principals(id, kind, display_name, created_at, updated_at)
245
+ VALUES (?, 'human', ?, ?, ?)
246
+ """,
247
+ (principal_id, name, timestamp, timestamp),
248
+ )
249
+ connection.execute(
250
+ """
251
+ INSERT INTO human_accounts(principal_id, email_normalized, created_at)
252
+ VALUES (?, ?, ?)
253
+ """,
254
+ (principal_id, email_normalized, timestamp),
255
+ )
256
+ connection.execute(
257
+ """
258
+ INSERT INTO teams(
259
+ id, kind, slug, display_name, personal_owner_principal_id,
260
+ created_by_principal_id, created_at, updated_at
261
+ ) VALUES (?, 'personal', ?, ?, ?, ?, ?, ?)
262
+ """,
263
+ (
264
+ team_id,
265
+ f"personal-{team_id[-24:]}",
266
+ team_display_name,
267
+ principal_id,
268
+ principal_id,
269
+ timestamp,
270
+ timestamp,
271
+ ),
272
+ )
273
+ connection.execute(
274
+ """
275
+ INSERT INTO memberships(
276
+ id, team_id, principal_id, role, status, created_at, updated_at
277
+ ) VALUES (?, ?, ?, 'owner', 'active', ?, ?)
278
+ """,
279
+ (membership_id, team_id, principal_id, timestamp, timestamp),
280
+ )
281
+ return BootstrapResult(principal_id, team_id, membership_id, True)
282
+
283
+
284
+ def record_legacy_server_binding(
285
+ connection: sqlite3.Connection,
286
+ team_id: str,
287
+ server_identity: str,
288
+ bound_by_principal_id: str,
289
+ *,
290
+ now: int | None = None,
291
+ ) -> str:
292
+ """Bind a stable legacy server identity without importing its bearer secret."""
293
+
294
+ identity = _identity(server_identity)
295
+ timestamp = _timestamp(now)
296
+ with _write_transaction(connection):
297
+ _require_team_role(
298
+ connection, team_id, bound_by_principal_id, ("owner", "admin")
299
+ )
300
+ existing = connection.execute(
301
+ "SELECT id, team_id FROM legacy_server_bindings WHERE server_identity = ?",
302
+ (identity,),
303
+ ).fetchone()
304
+ if existing is not None:
305
+ if not hmac.compare_digest(str(existing["team_id"]), team_id):
306
+ raise AuthorizationError("server identity is already bound to another team")
307
+ return str(existing["id"])
308
+ enrolled_node = connection.execute(
309
+ "SELECT id, team_id FROM nodes WHERE server_identity = ?", (identity,)
310
+ ).fetchone()
311
+ if enrolled_node is not None and not hmac.compare_digest(
312
+ str(enrolled_node["team_id"]), team_id
313
+ ):
314
+ raise AuthorizationError("server identity is enrolled in another team")
315
+ binding_id = _id("legacy_binding")
316
+ connection.execute(
317
+ """
318
+ INSERT INTO legacy_server_bindings(
319
+ id, team_id, server_identity, node_id, created_at
320
+ ) VALUES (?, ?, ?, ?, ?)
321
+ """,
322
+ (
323
+ binding_id,
324
+ team_id,
325
+ identity,
326
+ enrolled_node["id"] if enrolled_node is not None else None,
327
+ timestamp,
328
+ ),
329
+ )
330
+ return binding_id
331
+
332
+
333
+ def _insert_invitation(
334
+ connection: sqlite3.Connection,
335
+ team_id: str,
336
+ issued_by_principal_id: str,
337
+ role: str,
338
+ *,
339
+ invitee_email: str,
340
+ ttl_seconds: int = 15 * 60,
341
+ now: int | None = None,
342
+ ) -> IssuedSecret:
343
+ if role not in ("admin", "member", "guest"):
344
+ raise ValueError("invitation role must be admin, member, or guest")
345
+ email_normalized = _email(invitee_email)
346
+ timestamp = _timestamp(now)
347
+ expires_at = timestamp + _ttl(ttl_seconds, INVITATION_MAX_TTL_SECONDS)
348
+ token, token_hash = _new_secret()
349
+ invitation_id = _id("invite")
350
+ connection.execute(
351
+ """
352
+ INSERT INTO invitations(
353
+ id, team_id, token_hash, invitee_email_normalized, role,
354
+ issued_by_principal_id, expires_at, created_at
355
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
356
+ """,
357
+ (
358
+ invitation_id,
359
+ team_id,
360
+ token_hash,
361
+ email_normalized,
362
+ role,
363
+ issued_by_principal_id,
364
+ expires_at,
365
+ timestamp,
366
+ ),
367
+ )
368
+ return IssuedSecret(invitation_id, token, expires_at)
369
+
370
+
371
+ def issue_invitation(
372
+ connection: sqlite3.Connection,
373
+ team_id: str,
374
+ issued_by_principal_id: str,
375
+ role: str,
376
+ *,
377
+ invitee_email: str,
378
+ ttl_seconds: int = 15 * 60,
379
+ now: int | None = None,
380
+ ) -> IssuedSecret:
381
+ """Issue a one-time, short-lived invitation as an owner or admin."""
382
+
383
+ with _write_transaction(connection):
384
+ _require_team_role(
385
+ connection, team_id, issued_by_principal_id, ("owner", "admin")
386
+ )
387
+ return _insert_invitation(
388
+ connection,
389
+ team_id,
390
+ issued_by_principal_id,
391
+ role,
392
+ invitee_email=invitee_email,
393
+ ttl_seconds=ttl_seconds,
394
+ now=now,
395
+ )
396
+
397
+
398
+ def issue_managed_host_invitation(
399
+ connection: sqlite3.Connection,
400
+ team_id: str,
401
+ issued_by_principal_id: str,
402
+ role: str,
403
+ *,
404
+ invitee_email: str,
405
+ ttl_seconds: int = 15 * 60,
406
+ now: int | None = None,
407
+ ) -> IssuedSecret:
408
+ """Issue an invitation as this Hub's exact active managed host actor."""
409
+
410
+ with _write_transaction(connection):
411
+ if issued_by_principal_id != MANAGED_SERVER_PRINCIPAL_ID:
412
+ raise AuthorizationError("principal is not the managed host operator")
413
+ authorized = connection.execute(
414
+ """
415
+ SELECT 1
416
+ FROM principals AS p
417
+ JOIN service_accounts AS s ON s.principal_id=p.id
418
+ JOIN memberships AS m ON m.principal_id=p.id AND m.team_id=?
419
+ JOIN nodes AS n ON n.team_id=m.team_id
420
+ JOIN managed_host_bindings AS managed
421
+ ON managed.singleton=1 AND managed.server_identity=n.server_identity
422
+ WHERE p.id=? AND p.kind='service' AND p.scope_team_id IS NULL
423
+ AND p.status='active' AND s.service_identifier=?
424
+ AND m.role='automation' AND m.status='active'
425
+ AND n.status='active'
426
+ """,
427
+ (
428
+ team_id,
429
+ MANAGED_SERVER_PRINCIPAL_ID,
430
+ MANAGED_SERVER_SERVICE_IDENTIFIER,
431
+ ),
432
+ ).fetchone()
433
+ if authorized is None:
434
+ raise AuthorizationError("managed host operator is unavailable")
435
+ return _insert_invitation(
436
+ connection,
437
+ team_id,
438
+ issued_by_principal_id,
439
+ role,
440
+ invitee_email=invitee_email,
441
+ ttl_seconds=ttl_seconds,
442
+ now=now,
443
+ )
444
+
445
+
446
+ def redeem_invitation(
447
+ connection: sqlite3.Connection,
448
+ token: str,
449
+ human_principal_id: str,
450
+ *,
451
+ now: int | None = None,
452
+ ) -> str:
453
+ """Atomically consume an invitation and create its exact membership."""
454
+
455
+ token_hash = _token_digest(token)
456
+ timestamp = _timestamp(now)
457
+ with _write_transaction(connection):
458
+ invitation = connection.execute(
459
+ """
460
+ SELECT i.id, i.team_id, i.token_hash, i.invitee_email_normalized,
461
+ i.role, i.issued_by_principal_id, i.expires_at,
462
+ i.redeemed_at, i.revoked_at
463
+ FROM invitations AS i
464
+ JOIN memberships AS issuer
465
+ ON issuer.team_id = i.team_id
466
+ AND issuer.principal_id = i.issued_by_principal_id
467
+ AND issuer.status = 'active'
468
+ JOIN principals AS issuer_principal
469
+ ON issuer_principal.id = issuer.principal_id
470
+ AND issuer_principal.status = 'active'
471
+ LEFT JOIN service_accounts AS issuer_service
472
+ ON issuer_service.principal_id = issuer_principal.id
473
+ WHERE i.token_hash = ? AND (
474
+ issuer.role IN ('owner', 'admin')
475
+ OR (
476
+ issuer.role = 'automation'
477
+ AND issuer_principal.id = ?
478
+ AND issuer_principal.kind = 'service'
479
+ AND issuer_principal.scope_team_id IS NULL
480
+ AND issuer_service.service_identifier = ?
481
+ AND EXISTS (
482
+ SELECT 1
483
+ FROM nodes AS managed_node
484
+ JOIN managed_host_bindings AS managed
485
+ ON managed.singleton=1
486
+ AND managed.server_identity=managed_node.server_identity
487
+ WHERE managed_node.team_id=i.team_id
488
+ AND managed_node.status='active'
489
+ )
490
+ )
491
+ )
492
+ """,
493
+ (
494
+ token_hash,
495
+ MANAGED_SERVER_PRINCIPAL_ID,
496
+ MANAGED_SERVER_SERVICE_IDENTIFIER,
497
+ ),
498
+ ).fetchone()
499
+ if invitation is None or not hmac.compare_digest(invitation["token_hash"], token_hash):
500
+ raise AuthenticationError("credential is invalid or unavailable")
501
+ if (
502
+ invitation["redeemed_at"] is not None
503
+ or invitation["revoked_at"] is not None
504
+ or int(invitation["expires_at"]) <= timestamp
505
+ ):
506
+ raise AuthenticationError("credential is invalid or unavailable")
507
+ human = connection.execute(
508
+ """
509
+ SELECT h.email_normalized
510
+ FROM human_accounts AS h
511
+ JOIN principals AS p ON p.id = h.principal_id
512
+ WHERE h.principal_id = ? AND p.status = 'active'
513
+ """,
514
+ (human_principal_id,),
515
+ ).fetchone()
516
+ if human is None:
517
+ raise AuthenticationError("human account is unavailable")
518
+ bound_email = invitation["invitee_email_normalized"]
519
+ if bound_email is None or str(bound_email) != str(human["email_normalized"]):
520
+ raise AuthenticationError("credential is invalid or unavailable")
521
+
522
+ existing = connection.execute(
523
+ """
524
+ SELECT id, role, status FROM memberships
525
+ WHERE team_id = ? AND principal_id = ?
526
+ """,
527
+ (invitation["team_id"], human_principal_id),
528
+ ).fetchone()
529
+ if existing is not None:
530
+ if existing["status"] != "active" or existing["role"] != invitation["role"]:
531
+ raise AuthorizationError("existing membership conflicts with invitation")
532
+ membership_id = str(existing["id"])
533
+ else:
534
+ membership_id = _id("membership")
535
+ connection.execute(
536
+ """
537
+ INSERT INTO memberships(
538
+ id, team_id, principal_id, role, status,
539
+ invited_by_principal_id, created_at, updated_at
540
+ ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
541
+ """,
542
+ (
543
+ membership_id,
544
+ invitation["team_id"],
545
+ human_principal_id,
546
+ invitation["role"],
547
+ invitation["issued_by_principal_id"],
548
+ timestamp,
549
+ timestamp,
550
+ ),
551
+ )
552
+ changed = connection.execute(
553
+ """
554
+ UPDATE invitations
555
+ SET redeemed_at = ?, redeemed_by_principal_id = ?
556
+ WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > ?
557
+ """,
558
+ (timestamp, human_principal_id, invitation["id"], timestamp),
559
+ ).rowcount
560
+ if changed != 1:
561
+ raise AuthenticationError("credential is invalid or unavailable")
562
+ return membership_id
563
+
564
+
565
+ def issue_node_enrollment(
566
+ connection: sqlite3.Connection,
567
+ team_id: str,
568
+ issued_by_principal_id: str,
569
+ *,
570
+ ttl_seconds: int = 5 * 60,
571
+ now: int | None = None,
572
+ ) -> IssuedSecret:
573
+ """Issue a distinct one-time node enrollment credential."""
574
+
575
+ timestamp = _timestamp(now)
576
+ expires_at = timestamp + _ttl(ttl_seconds, NODE_ENROLLMENT_MAX_TTL_SECONDS)
577
+ token, token_hash = _new_secret()
578
+ grant_id = _id("node_grant")
579
+ with _write_transaction(connection):
580
+ _require_team_role(
581
+ connection, team_id, issued_by_principal_id, ("owner", "admin")
582
+ )
583
+ connection.execute(
584
+ """
585
+ INSERT INTO node_enrollment_grants(
586
+ id, team_id, token_hash, issued_by_principal_id,
587
+ created_at, expires_at
588
+ ) VALUES (?, ?, ?, ?, ?, ?)
589
+ """,
590
+ (grant_id, team_id, token_hash, issued_by_principal_id, timestamp, expires_at),
591
+ )
592
+ return IssuedSecret(grant_id, token, expires_at)
593
+
594
+
595
+ def redeem_node_enrollment(
596
+ connection: sqlite3.Connection,
597
+ token: str,
598
+ server_identity: str,
599
+ display_name: str,
600
+ credential_kind: str,
601
+ public_material: str,
602
+ *,
603
+ now: int | None = None,
604
+ ) -> EnrollmentResult:
605
+ """Atomically consume a grant and enroll a public node credential."""
606
+
607
+ token_hash = _token_digest(token)
608
+ identity = _identity(server_identity)
609
+ name = _bounded_text(display_name, "display_name", 1, 160)
610
+ if credential_kind != "ed25519":
611
+ raise ValueError("credential_kind must be ed25519")
612
+ public_key, fingerprint = _canonical_ed25519_public_key(public_material)
613
+ timestamp = _timestamp(now)
614
+
615
+ with _write_transaction(connection):
616
+ grant = connection.execute(
617
+ """
618
+ SELECT grant.id, grant.team_id, grant.token_hash, grant.expires_at,
619
+ grant.consumed_at, grant.revoked_at
620
+ FROM node_enrollment_grants AS grant
621
+ JOIN memberships AS issuer
622
+ ON issuer.team_id = grant.team_id
623
+ AND issuer.principal_id = grant.issued_by_principal_id
624
+ AND issuer.status = 'active'
625
+ AND issuer.role IN ('owner', 'admin')
626
+ JOIN principals AS issuer_principal
627
+ ON issuer_principal.id = issuer.principal_id
628
+ AND issuer_principal.status = 'active'
629
+ WHERE grant.token_hash = ?
630
+ """,
631
+ (token_hash,),
632
+ ).fetchone()
633
+ if grant is None or not hmac.compare_digest(grant["token_hash"], token_hash):
634
+ raise AuthenticationError("credential is invalid or unavailable")
635
+ if (
636
+ grant["consumed_at"] is not None
637
+ or grant["revoked_at"] is not None
638
+ or int(grant["expires_at"]) <= timestamp
639
+ ):
640
+ raise AuthenticationError("credential is invalid or unavailable")
641
+
642
+ principal_id = _id("node_principal")
643
+ node_id = _id("node")
644
+ credential_id = _id("node_credential")
645
+ team_id = str(grant["team_id"])
646
+ binding = connection.execute(
647
+ """
648
+ SELECT team_id, node_id FROM legacy_server_bindings
649
+ WHERE server_identity = ?
650
+ """,
651
+ (identity,),
652
+ ).fetchone()
653
+ if binding is not None and (
654
+ not hmac.compare_digest(str(binding["team_id"]), team_id)
655
+ or binding["node_id"] is not None
656
+ ):
657
+ raise AuthorizationError("server identity is unavailable for enrollment")
658
+ connection.execute(
659
+ """
660
+ INSERT INTO principals(
661
+ id, kind, scope_team_id, display_name, created_at, updated_at
662
+ ) VALUES (?, 'node', ?, ?, ?, ?)
663
+ """,
664
+ (principal_id, team_id, name, timestamp, timestamp),
665
+ )
666
+ connection.execute(
667
+ """
668
+ INSERT INTO nodes(
669
+ id, team_id, principal_id, server_identity,
670
+ display_name, enrolled_at
671
+ ) VALUES (?, ?, ?, ?, ?, ?)
672
+ """,
673
+ (node_id, team_id, principal_id, identity, name, timestamp),
674
+ )
675
+ connection.execute(
676
+ """
677
+ INSERT INTO node_credentials(
678
+ id, team_id, node_id, credential_kind,
679
+ public_material, fingerprint_sha256, created_at
680
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
681
+ """,
682
+ (
683
+ credential_id,
684
+ team_id,
685
+ node_id,
686
+ credential_kind,
687
+ public_key,
688
+ fingerprint,
689
+ timestamp,
690
+ ),
691
+ )
692
+ if binding is not None:
693
+ connection.execute(
694
+ """
695
+ UPDATE legacy_server_bindings SET node_id = ?
696
+ WHERE team_id = ? AND server_identity = ? AND node_id IS NULL
697
+ """,
698
+ (node_id, team_id, identity),
699
+ )
700
+ changed = connection.execute(
701
+ """
702
+ UPDATE node_enrollment_grants
703
+ SET consumed_at = ?, consumed_by_node_id = ?
704
+ WHERE id = ? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at > ?
705
+ """,
706
+ (timestamp, node_id, grant["id"], timestamp),
707
+ ).rowcount
708
+ if changed != 1:
709
+ raise AuthenticationError("credential is invalid or unavailable")
710
+ return EnrollmentResult(team_id, node_id, principal_id, credential_id)