ltcai 8.9.0 → 9.1.0

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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -0,0 +1,46 @@
1
+ """Shared Brain Core utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ def parse_iso(value: Optional[str]) -> Optional[datetime]:
12
+ if not value:
13
+ return None
14
+ try:
15
+ return datetime.fromisoformat(str(value))
16
+ except (TypeError, ValueError):
17
+ return None
18
+
19
+
20
+ def local_now() -> datetime:
21
+ """Return local wall-clock time for legacy local persistence formats."""
22
+
23
+ return datetime.now()
24
+
25
+
26
+ def now_iso(*, timespec: str = "seconds") -> str:
27
+ """Return one consistently formatted local timestamp."""
28
+
29
+ return local_now().isoformat(timespec=timespec)
30
+
31
+
32
+ def utc_now_iso(*, timespec: str = "auto") -> str:
33
+ """Return an offset-aware UTC timestamp."""
34
+
35
+ return datetime.now(timezone.utc).isoformat(timespec=timespec)
36
+
37
+
38
+ def sha256_file(path: Path) -> str:
39
+ digest = hashlib.sha256()
40
+ with open(path, "rb") as fh:
41
+ for block in iter(lambda: fh.read(65536), b""):
42
+ digest.update(block)
43
+ return digest.hexdigest()
44
+
45
+
46
+ __all__ = ["local_now", "now_iso", "parse_iso", "sha256_file", "utc_now_iso"]
@@ -24,10 +24,10 @@ a linear node chain so existing workflow history keeps working.
24
24
  from __future__ import annotations
25
25
 
26
26
  from dataclasses import dataclass, field
27
- from datetime import datetime
28
27
  from typing import Any, Callable, Dict, List, Optional
29
28
 
30
29
  from lattice_brain.runtime.contracts import runtime_boundary_contract, workflow_run_contract
30
+ from lattice_brain.utils import now_iso as _now
31
31
 
32
32
 
33
33
  WORKFLOW_ENGINE_VERSION = "2.2.0"
@@ -59,10 +59,6 @@ class WorkflowError(Exception):
59
59
  """Raised for invalid workflow definitions."""
60
60
 
61
61
 
62
- def _now() -> str:
63
- return datetime.now().isoformat(timespec="seconds")
64
-
65
-
66
62
  def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
67
63
  """Return a node-based definition, lifting legacy ``steps`` lists if needed.
68
64
 
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "8.9.0"
3
+ __version__ = "9.1.0"
@@ -259,7 +259,7 @@ def create_admin_router(
259
259
  {"id": "model_egress", "label": "Model egress",
260
260
  "value": "Local-only by default (no external inference in local mode)", "enforced": True},
261
261
  {"id": "invite_gate", "label": "Invite gate",
262
- "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
262
+ "value": "Signed access gate" if invite_gate_enabled else "Disabled",
263
263
  "enforced": bool(invite_gate_enabled)},
264
264
  {"id": "log_retention", "label": "Log retention",
265
265
  "value": "90 day local audit window with manual export before pruning", "enforced": True},
@@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
14
14
  from pydantic import BaseModel
15
15
 
16
16
  from latticeai.core.agent_registry import AgentRegistry
17
+ from latticeai.core.security import redact_secrets
17
18
 
18
19
 
19
20
  class AgentRegisterRequest(BaseModel):
@@ -34,6 +35,7 @@ def create_agent_registry_router(
34
35
  *,
35
36
  registry: AgentRegistry,
36
37
  require_user: Callable[[Request], str],
38
+ require_admin: Callable[[Request], Any],
37
39
  append_audit_event: Callable[..., None],
38
40
  ) -> APIRouter:
39
41
  router = APIRouter()
@@ -41,7 +43,7 @@ def create_agent_registry_router(
41
43
  @router.get("/agents/api/registry")
42
44
  async def list_registry(request: Request, type: Optional[str] = None):
43
45
  require_user(request)
44
- return registry.list(agent_type=type)
46
+ return redact_secrets(registry.list(agent_type=type))
45
47
 
46
48
  @router.get("/agents/api/registry/capabilities")
47
49
  async def registry_capabilities(request: Request):
@@ -51,11 +53,11 @@ def create_agent_registry_router(
51
53
  @router.get("/agents/api/registry/discover")
52
54
  async def registry_discover(request: Request, capability: str = ""):
53
55
  require_user(request)
54
- return {"capability": capability, "agents": registry.discover(capability)}
56
+ return {"capability": capability, "agents": redact_secrets(registry.discover(capability))}
55
57
 
56
58
  @router.post("/agents/api/registry")
57
59
  async def register_agent(req: AgentRegisterRequest, request: Request):
58
- user = require_user(request)
60
+ user, _ = require_admin(request)
59
61
  try:
60
62
  entry = registry.register(
61
63
  name=req.name,
@@ -68,7 +70,7 @@ def create_agent_registry_router(
68
70
  except ValueError as exc:
69
71
  raise HTTPException(status_code=400, detail=str(exc)) from exc
70
72
  append_audit_event("agent_register", user_email=user, agent_id=entry["id"], type=entry["type"])
71
- return {"agent": entry}
73
+ return {"agent": redact_secrets(entry)}
72
74
 
73
75
  @router.get("/agents/api/registry/{agent_id:path}")
74
76
  async def get_agent(agent_id: str, request: Request):
@@ -76,21 +78,21 @@ def create_agent_registry_router(
76
78
  agent = registry.get(agent_id)
77
79
  if agent is None:
78
80
  raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
79
- return {"agent": agent}
81
+ return {"agent": redact_secrets(agent)}
80
82
 
81
83
  @router.patch("/agents/api/registry/{agent_id:path}")
82
84
  async def update_agent(agent_id: str, req: AgentConfigRequest, request: Request):
83
- user = require_user(request)
85
+ user, _ = require_admin(request)
84
86
  try:
85
87
  agent = registry.update_config(agent_id, req.config, enabled=req.enabled)
86
88
  except KeyError as exc:
87
89
  raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") from exc
88
90
  append_audit_event("agent_config", user_email=user, agent_id=agent_id)
89
- return {"agent": agent}
91
+ return {"agent": redact_secrets(agent)}
90
92
 
91
93
  @router.delete("/agents/api/registry/{agent_id:path}")
92
94
  async def remove_agent(agent_id: str, request: Request):
93
- user = require_user(request)
95
+ user, _ = require_admin(request)
94
96
  try:
95
97
  result = registry.remove(agent_id)
96
98
  except KeyError as exc:
@@ -17,6 +17,24 @@ from pydantic import BaseModel
17
17
  from latticeai.api.ui_redirects import app_redirect
18
18
 
19
19
 
20
+ _CORE_EXECUTION_ROLES = ["planner", "executor", "reviewer"]
21
+ _MEMORY_GROUNDED_ROLES = ["researcher", *_CORE_EXECUTION_ROLES]
22
+
23
+
24
+ def _memory_grounded_roles(roles: List[str]) -> Optional[List[str]]:
25
+ """Ground standard user-initiated agent runs in Brain recall first.
26
+
27
+ Explicit specialist/custom pipelines keep their requested shape. The
28
+ desktop Brain and Work surfaces both send the historical three-role core
29
+ sequence, so normalizing it at the API boundary upgrades existing clients
30
+ without a frontend-only compatibility branch.
31
+ """
32
+ requested = list(roles or _CORE_EXECUTION_ROLES)
33
+ if requested == _CORE_EXECUTION_ROLES:
34
+ return list(_MEMORY_GROUNDED_ROLES)
35
+ return requested or None
36
+
37
+
20
38
  class AgentRunRequest(BaseModel):
21
39
  goal: str
22
40
  roles: List[str] = []
@@ -160,13 +178,14 @@ def create_agents_router(
160
178
  async def agent_run(req: AgentRunRequest, request: Request):
161
179
  current_user = require_user(request)
162
180
  scope = gate_write(request)
181
+ grounded_roles = _memory_grounded_roles(req.roles)
163
182
  try:
164
183
  if run_executor is not None:
165
184
  return await run_executor.start_agent(
166
185
  req.goal,
167
186
  user_email=current_user or None,
168
187
  scope=scope,
169
- roles=req.roles or None,
188
+ roles=grounded_roles,
170
189
  inputs=req.inputs,
171
190
  max_retries=req.max_retries,
172
191
  )
@@ -180,7 +199,7 @@ def create_agents_router(
180
199
  req.goal,
181
200
  user_email=current_user or None,
182
201
  scope=scope,
183
- roles=req.roles or None,
202
+ roles=grounded_roles,
184
203
  inputs=req.inputs,
185
204
  max_retries=req.max_retries,
186
205
  )
@@ -199,7 +218,7 @@ def create_agents_router(
199
218
  return runtime.preview(
200
219
  req.goal,
201
220
  scope=scope,
202
- roles=req.roles or None,
221
+ roles=_memory_grounded_roles(req.roles),
203
222
  inputs=req.inputs,
204
223
  max_retries=req.max_retries,
205
224
  )
@@ -5,7 +5,8 @@ import hashlib
5
5
  import logging
6
6
  import secrets
7
7
  import time
8
- from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
8
+ from dataclasses import dataclass
9
+ from typing import Any, Awaitable, Callable, Dict, Optional
9
10
  from urllib.parse import urlencode
10
11
 
11
12
  from fastapi import APIRouter, HTTPException, Request
@@ -42,9 +43,20 @@ class UpdateProfileRequest(BaseModel):
42
43
  nickname: Optional[str] = None
43
44
 
44
45
 
45
- # state → (issued_at, nonce). The nonce binds the eventual ID token to *this*
46
- # login attempt (replay / token-injection defence); the timestamp expires it.
47
- _sso_states: Dict[str, Tuple[float, str]] = {}
46
+ @dataclass(frozen=True)
47
+ class _SSOLoginState:
48
+ """Server-side SSO transaction state consumed exactly once."""
49
+
50
+ issued_at: float
51
+ nonce: str
52
+ code_verifier: str
53
+ invite_authorized: bool
54
+
55
+
56
+ # The nonce and PKCE verifier bind the callback/token to this login attempt.
57
+ # The signed invite cookie is verified once at login and reduced to a
58
+ # server-side claim, so the callback never trusts a client-supplied invite bit.
59
+ _sso_states: Dict[str, _SSOLoginState] = {}
48
60
 
49
61
 
50
62
  def create_auth_router(
@@ -67,7 +79,10 @@ def create_auth_router(
67
79
  open_registration: bool,
68
80
  session_ttl: int,
69
81
  require_auth: bool = True,
82
+ secure_cookies: bool = False,
70
83
  ensure_identity: Optional[Callable[[str, Dict], None]] = None,
84
+ invite_gate_enabled: bool = False,
85
+ invite_authorized: Optional[Callable[[Request], bool]] = None,
71
86
  verify_id_token: Callable[..., Dict] = _default_verify_id_token,
72
87
  fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
73
88
  ) -> APIRouter:
@@ -86,7 +101,19 @@ def create_auth_router(
86
101
  @router.post("/register")
87
102
  async def register(req: UserRegister, request: Request):
88
103
  check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
89
- if not open_registration:
104
+ invite_claim = bool(
105
+ invite_gate_enabled
106
+ and invite_authorized is not None
107
+ and invite_authorized(request)
108
+ )
109
+ if invite_gate_enabled and not invite_claim:
110
+ raise HTTPException(
111
+ status_code=403,
112
+ detail="유효한 서명 초대 권한이 필요합니다.",
113
+ )
114
+ # Closed public registration stays closed unless the operator enabled
115
+ # the invite gate and this exact request carries a valid signed claim.
116
+ if not open_registration and not invite_claim:
90
117
  raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
91
118
  _enforce_password_policy(req.password)
92
119
  email = normalize_email(req.email)
@@ -127,7 +154,14 @@ def create_auth_router(
127
154
  "role": role,
128
155
  "is_admin": role == "admin",
129
156
  })
130
- response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=session_ttl)
157
+ response.set_cookie(
158
+ key="session_token",
159
+ value=token,
160
+ httponly=True,
161
+ secure=secure_cookies,
162
+ samesite="lax",
163
+ max_age=session_ttl,
164
+ )
131
165
  return response
132
166
 
133
167
  @router.get("/auth/sso/config")
@@ -135,7 +169,7 @@ def create_auth_router(
135
169
  return public_sso_config()
136
170
 
137
171
  @router.get("/auth/sso/login")
138
- async def sso_login():
172
+ async def sso_login(request: Request):
139
173
  settings = get_sso_settings()
140
174
  discovery = await get_sso_discovery()
141
175
  if not settings.get("enabled") or not discovery:
@@ -150,7 +184,19 @@ def create_auth_router(
150
184
  .rstrip(b"=")
151
185
  .decode("ascii")
152
186
  )
153
- _sso_states[state] = (time.time(), nonce, code_verifier)
187
+ invite_claim = bool(
188
+ not invite_gate_enabled
189
+ or (
190
+ invite_authorized is not None
191
+ and invite_authorized(request)
192
+ )
193
+ )
194
+ _sso_states[state] = _SSOLoginState(
195
+ issued_at=time.time(),
196
+ nonce=nonce,
197
+ code_verifier=code_verifier,
198
+ invite_authorized=invite_claim,
199
+ )
154
200
  params = urlencode({
155
201
  "client_id": settings["client_id"],
156
202
  "response_type": "code",
@@ -165,12 +211,11 @@ def create_auth_router(
165
211
 
166
212
  @router.get("/auth/sso/callback")
167
213
  async def sso_callback(code: str = "", state: str = "", error: str = ""):
168
- if error:
169
- return RedirectResponse(f"/?sso_error={error}")
170
214
  entry = _sso_states.pop(state, None)
171
- if entry is None or time.time() - entry[0] > 300:
215
+ if entry is None or time.time() - entry.issued_at > 300:
172
216
  raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
173
- _, nonce, code_verifier = entry
217
+ if error:
218
+ return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
174
219
  settings = get_sso_settings()
175
220
  discovery = await get_sso_discovery()
176
221
  if not settings.get("enabled") or not discovery:
@@ -183,7 +228,7 @@ def create_auth_router(
183
228
  "redirect_uri": settings["redirect_uri"],
184
229
  "client_id": settings["client_id"],
185
230
  "client_secret": settings["client_secret"],
186
- "code_verifier": code_verifier,
231
+ "code_verifier": entry.code_verifier,
187
232
  }, headers={"Accept": "application/json"}, timeout=15)
188
233
  tokens = r.json()
189
234
  id_token = tokens.get("id_token")
@@ -200,7 +245,7 @@ def create_auth_router(
200
245
  jwks=jwks,
201
246
  issuer=issuer,
202
247
  audience=settings["client_id"],
203
- nonce=nonce,
248
+ nonce=entry.nonce,
204
249
  )
205
250
  except OIDCValidationError as exc:
206
251
  logging.warning("SSO ID token rejected: %s", exc)
@@ -213,6 +258,11 @@ def create_auth_router(
213
258
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
214
259
  users = load_users()
215
260
  if email not in users:
261
+ if invite_gate_enabled and not entry.invite_authorized:
262
+ raise HTTPException(
263
+ status_code=403,
264
+ detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
265
+ )
216
266
  is_first = len(users) == 0
217
267
  users[email] = {
218
268
  "password": "",
@@ -229,7 +279,14 @@ def create_auth_router(
229
279
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
230
280
  token = create_session(email)
231
281
  resp = RedirectResponse("/app", status_code=302)
232
- resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=session_ttl)
282
+ resp.set_cookie(
283
+ "session_token",
284
+ token,
285
+ httponly=True,
286
+ secure=secure_cookies,
287
+ samesite="lax",
288
+ max_age=session_ttl,
289
+ )
233
290
  return resp
234
291
 
235
292
  @router.post("/logout")
@@ -238,7 +295,12 @@ def create_auth_router(
238
295
  if token:
239
296
  invalidate_session(token)
240
297
  response = JSONResponse(content={"status": "ok"})
241
- response.delete_cookie("session_token")
298
+ response.delete_cookie(
299
+ "session_token",
300
+ secure=secure_cookies,
301
+ httponly=True,
302
+ samesite="lax",
303
+ )
242
304
  return response
243
305
 
244
306
  @router.post("/account/change-password")