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,56 @@
1
+ """VPC network-profile config seam extracted from the legacy app factory.
2
+
3
+ ``DEFAULT_VPC_CONFIG`` and the load/save helpers used to live inline in
4
+ ``app_factory._build``. Behaviour and exported names are preserved exactly for
5
+ the legacy ``server_app`` compatibility namespace; the only change is that they
6
+ now live behind a builder so the factory stays a wiring path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict
16
+
17
+
18
+ def build_vpc_runtime(*, vpc_file: Path, logging: Any) -> Dict[str, Any]:
19
+ """Return ``DEFAULT_VPC_CONFIG`` and the VPC load/save helpers."""
20
+
21
+ default_vpc_config: Dict[str, Any] = {
22
+ "provider": "AWS",
23
+ "region": "ap-northeast-2",
24
+ "cidr_block": "10.42.0.0/16",
25
+ "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
26
+ "endpoint": "ltcai-private.local",
27
+ "vpn_status": "standby",
28
+ "peering_status": "not_configured",
29
+ "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
30
+ "updated_at": None,
31
+ }
32
+
33
+ def load_vpc_config() -> Dict:
34
+ if not os.path.exists(vpc_file):
35
+ return default_vpc_config.copy()
36
+ try:
37
+ with open(vpc_file, "r", encoding="utf-8") as f:
38
+ stored = json.load(f)
39
+ return {**default_vpc_config, **stored}
40
+ except Exception as e:
41
+ logging.warning("load_vpc_config failed (using defaults): %s", e)
42
+ return default_vpc_config.copy()
43
+
44
+ def save_vpc_config(config: Dict) -> None:
45
+ config["updated_at"] = datetime.now().isoformat()
46
+ with open(vpc_file, "w", encoding="utf-8") as f:
47
+ json.dump(config, f, ensure_ascii=False, indent=2)
48
+
49
+ return {
50
+ "DEFAULT_VPC_CONFIG": default_vpc_config,
51
+ "load_vpc_config": load_vpc_config,
52
+ "save_vpc_config": save_vpc_config,
53
+ }
54
+
55
+
56
+ __all__ = ["build_vpc_runtime"]
@@ -24,13 +24,13 @@ def build_platform_automation_runtime(
24
24
  agent_registry: Any,
25
25
  data_dir: Any,
26
26
  append_audit_event: Callable[..., Any],
27
+ memory_service: Any = None,
27
28
  tz_name: Optional[str] = None,
28
29
  ) -> Dict[str, Any]:
29
30
  """Build platform services, automation services, and hook bindings.
30
31
 
31
- The returned names intentionally match the historical local variables in
32
- ``app_factory._build`` so ``dict(locals())`` continues to expose the legacy
33
- ``server_app`` compatibility surface.
32
+ The returned names intentionally match the explicit composition-root
33
+ bindings consumed by the typed application stages.
34
34
  """
35
35
  from latticeai.runtime.automation_runtime import build_automation_runtime
36
36
  from latticeai.services.platform_runtime import PlatformRuntime
@@ -65,6 +65,7 @@ def build_platform_automation_runtime(
65
65
  llm_generate=_llm_generate_sync,
66
66
  llm_available=lambda: bool(getattr(model_router, "current_model_id", None)),
67
67
  agent_registry=agent_registry,
68
+ memory_recall=memory_service.recall if memory_service is not None else None,
68
69
  )
69
70
 
70
71
  automation_runtime = build_automation_runtime(
@@ -27,7 +27,7 @@ def build_review_run_now_runner(platform: Any, http_exception: type[Exception])
27
27
  workflow_id,
28
28
  user_email,
29
29
  scope,
30
- with_agent=False,
30
+ with_agent=True,
31
31
  inputs={"__review_item__": item.get("id")},
32
32
  )
33
33
 
@@ -8,17 +8,33 @@ exact include order while creating a narrow seam for the later
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from dataclasses import dataclass
11
12
  from typing import Any
12
13
 
13
14
  from latticeai.services.router_context import InteractionRouterContext
14
15
 
15
16
 
17
+ @dataclass(frozen=True)
18
+ class RouterBundle:
19
+ """Final typed router stage after all domain routers are registered."""
20
+
21
+ app: Any
22
+ context: Any
23
+ route_count: int
24
+
25
+
26
+ def build_router_bundle(app: Any, context: Any) -> RouterBundle:
27
+ return RouterBundle(app=app, context=context, route_count=len(getattr(app, "routes", ())))
28
+
29
+
16
30
  def build_static_routes_bundle(
17
31
  *,
18
32
  create_static_routes_router: Any,
19
33
  static_dir: Any,
20
34
  invite_gate_enabled: bool,
21
35
  invite_code: str,
36
+ invite_cookie_secret: str,
37
+ secure_cookies: bool,
22
38
  app_mode: str,
23
39
  model_router: Any,
24
40
  require_user: Any,
@@ -29,6 +45,8 @@ def build_static_routes_bundle(
29
45
  static_dir=static_dir,
30
46
  invite_gate_enabled=invite_gate_enabled,
31
47
  invite_code=invite_code,
48
+ invite_cookie_secret=invite_cookie_secret,
49
+ secure_cookies=secure_cookies,
32
50
  app_mode=app_mode,
33
51
  model_router=model_router,
34
52
  require_user=require_user,
@@ -37,6 +55,7 @@ def build_static_routes_bundle(
37
55
  "STATIC_ROUTES": static_routes,
38
56
  "ui_file_response": static_routes.ui_file_response,
39
57
  "local_sysinfo": static_routes.local_sysinfo,
58
+ "invite_authorized": static_routes.invite_authorized,
40
59
  }
41
60
 
42
61
 
@@ -61,6 +80,8 @@ def build_auth_admin_security_router_bundle(
61
80
  open_registration: bool,
62
81
  session_ttl: int,
63
82
  require_auth: bool,
83
+ secure_cookies: bool,
84
+ invite_authorized: Any,
64
85
  ensure_identity: Any,
65
86
  create_admin_router: Any,
66
87
  require_admin: Any,
@@ -113,6 +134,9 @@ def build_auth_admin_security_router_bundle(
113
134
  open_registration=open_registration,
114
135
  session_ttl=session_ttl,
115
136
  require_auth=require_auth,
137
+ secure_cookies=secure_cookies,
138
+ invite_gate_enabled=invite_gate_enabled,
139
+ invite_authorized=invite_authorized,
116
140
  ensure_identity=ensure_identity,
117
141
  )
118
142
 
@@ -290,8 +314,9 @@ def register_platform_feature_routers(
290
314
  require_user=require_user,
291
315
  require_admin=require_admin,
292
316
  append_audit_event=append_audit_event,
317
+ gate_write=platform.gate_write,
293
318
  register_skill=platform.register_plugin_skill,
294
- plugin_runners_factory=lambda: platform.plugin_capability_runners(None, None),
319
+ plugin_runners_factory=platform.plugin_capability_runners,
295
320
  ui_file_response=ui_file_response,
296
321
  static_dir=static_dir,
297
322
  ),
@@ -356,6 +381,7 @@ def register_health_and_model_routers(
356
381
  create_models_router: Any,
357
382
  model_router: Any,
358
383
  require_user: Any,
384
+ require_admin: Any,
359
385
  load_users: Any,
360
386
  get_user_role: Any,
361
387
  install_engine: Any,
@@ -391,6 +417,7 @@ def register_health_and_model_routers(
391
417
  create_models_router(
392
418
  model_router=model_router,
393
419
  require_user=require_user,
420
+ require_admin=require_admin,
394
421
  get_current_user=get_current_user,
395
422
  load_users=load_users,
396
423
  get_user_role=get_user_role,
@@ -469,6 +496,7 @@ def register_interaction_routers(
469
496
  require_user = interaction_context.require_user
470
497
  embedding_info = interaction_context.embedding_info
471
498
  tool_context = interaction_context.tool_context
499
+ require_admin = tool_context.require_admin
472
500
  get_current_user = tool_context.get_current_user
473
501
  append_audit_event = tool_context.append_audit_event
474
502
  hooks = interaction_context.hooks
@@ -515,11 +543,13 @@ def register_interaction_routers(
515
543
  create_hooks_router(
516
544
  registry=hooks,
517
545
  require_user=require_user,
546
+ require_admin=require_admin,
518
547
  append_audit_event=append_audit_event,
519
548
  ),
520
549
  create_agent_registry_router(
521
550
  registry=agent_registry,
522
551
  require_user=require_user,
552
+ require_admin=require_admin,
523
553
  append_audit_event=append_audit_event,
524
554
  ),
525
555
  create_memory_router(
@@ -546,6 +576,7 @@ def register_review_and_brain_tail_routers(
546
576
  append_audit_event: Any,
547
577
  create_browser_router: Any,
548
578
  ingestion_pipeline: Any,
579
+ workspace_service: Any,
549
580
  create_portability_router: Any,
550
581
  kg_portability: Any,
551
582
  require_admin: Any,
@@ -573,6 +604,7 @@ def register_review_and_brain_tail_routers(
573
604
  create_browser_router(
574
605
  pipeline=ingestion_pipeline,
575
606
  require_user=require_user,
607
+ workspace_service=workspace_service,
576
608
  ),
577
609
  create_portability_router(
578
610
  service=kg_portability,
@@ -591,6 +623,7 @@ def register_review_and_brain_tail_routers(
591
623
  network=brain_network,
592
624
  identity=device_identity,
593
625
  require_user=require_user,
626
+ require_admin=require_admin,
594
627
  ),
595
628
  create_garden_router(gardener=gardener, require_user=require_user),
596
629
  create_setup_router(model_router=model_router, require_user=require_user),
@@ -2,26 +2,123 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any, Dict
5
+ import json
6
+ import logging
7
+ import secrets
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Dict
11
+
12
+ from latticeai.runtime.stages import RuntimeStage
6
13
 
7
14
  if TYPE_CHECKING:
8
15
  from latticeai.core.config import Config
9
16
 
10
17
 
11
- def build_security_runtime(config: "Config") -> Dict[str, Any]:
18
+ _SECURITY_SECRETS_FILE = "security_secrets.json"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SecurityRuntime(RuntimeStage):
23
+ SSO_DISCOVERY_URL: str
24
+ SSO_CLIENT_ID: str
25
+ SSO_CLIENT_SECRET: str
26
+ SSO_REDIRECT_URI: str
27
+ SSO_PROVIDER_NAME: str
28
+ RATE_LIMIT_ENABLED: bool
29
+ OPEN_REGISTRATION: bool
30
+ INVITE_CODE: str
31
+ INVITE_COOKIE_SECRET: str
32
+ INVITE_GATE_ENABLED: bool
33
+ SECURE_COOKIES: bool
34
+
35
+
36
+ def _stored_security_secrets(data_dir: Path) -> Dict[str, str]:
37
+ path = data_dir / _SECURITY_SECRETS_FILE
38
+ if not path.exists():
39
+ return {}
40
+ try:
41
+ payload = json.loads(path.read_text(encoding="utf-8"))
42
+ except Exception as exc:
43
+ # A corrupt secret store invalidates old invite cookies, but must never
44
+ # make startup fall back to a shared or predictable signing key.
45
+ logging.warning("invite-gate secret store is unreadable; rotating secrets: %s", exc)
46
+ return {}
47
+ if not isinstance(payload, dict):
48
+ return {}
49
+ return {
50
+ key: str(value)
51
+ for key, value in payload.items()
52
+ if key in {"invite_code", "invite_cookie_secret"} and value
53
+ }
54
+
55
+
56
+ def _resolve_invite_gate_secrets(config: "Config") -> tuple[str, str]:
57
+ """Resolve stable, per-install invitation secrets.
58
+
59
+ No file is created while the invitation gate is disabled. Once enabled,
60
+ missing values are generated with the OS CSPRNG and persisted atomically
61
+ with mode 0600 so restarts do not invalidate links or signed cookies.
62
+ Explicit environment values remain authoritative and are not copied to the
63
+ local secret file unnecessarily.
64
+ """
65
+
66
+ if not config.invite_gate_enabled:
67
+ return config.invite_code, config.invite_cookie_secret
68
+
69
+ from latticeai.core.io_utils import atomic_write_json
70
+
71
+ data_dir = Path(config.data_dir)
72
+ stored = _stored_security_secrets(data_dir)
73
+ invite_code = config.invite_code or stored.get("invite_code") or secrets.token_urlsafe(24)
74
+ cookie_secret = (
75
+ config.invite_cookie_secret
76
+ or stored.get("invite_cookie_secret")
77
+ or secrets.token_urlsafe(48)
78
+ )
79
+
80
+ persisted = dict(stored)
81
+ if not config.invite_code:
82
+ persisted["invite_code"] = invite_code
83
+ if not config.invite_cookie_secret:
84
+ persisted["invite_cookie_secret"] = cookie_secret
85
+ if persisted != stored:
86
+ atomic_write_json(data_dir / _SECURITY_SECRETS_FILE, persisted)
87
+ return invite_code, cookie_secret
88
+
89
+
90
+ def build_security_runtime(config: "Config") -> SecurityRuntime:
12
91
  """Build auth/security-derived runtime settings from the central config."""
13
92
 
14
93
  from latticeai.core.security import configure_trusted_proxies
15
94
 
16
95
  configure_trusted_proxies(config.trusted_proxies)
17
- return {
18
- "SSO_DISCOVERY_URL": config.sso_discovery_url,
19
- "SSO_CLIENT_ID": config.sso_client_id,
20
- "SSO_CLIENT_SECRET": config.sso_client_secret,
21
- "SSO_REDIRECT_URI": config.sso_redirect_uri,
22
- "SSO_PROVIDER_NAME": config.sso_provider_name,
23
- "RATE_LIMIT_ENABLED": config.rate_limit_enabled,
24
- "OPEN_REGISTRATION": config.open_registration,
25
- "INVITE_CODE": config.invite_code,
26
- "INVITE_GATE_ENABLED": config.invite_gate_enabled,
27
- }
96
+ invite_code, invite_cookie_secret = _resolve_invite_gate_secrets(config)
97
+ secure_cookies = bool(config.is_public or config.network_exposed)
98
+ if secure_cookies:
99
+ logging.warning(
100
+ "Public/non-loopback mode: authentication and Secure cookies are forced; "
101
+ "serve Lattice AI through HTTPS/TLS or browser sessions will be rejected."
102
+ )
103
+ if config.invite_gate_enabled and not config.invite_code:
104
+ logging.warning(
105
+ "No LATTICEAI_INVITE_CODE was configured; generated a private per-install "
106
+ "invite code in %s.",
107
+ Path(config.data_dir) / _SECURITY_SECRETS_FILE,
108
+ )
109
+ return SecurityRuntime(
110
+ SSO_DISCOVERY_URL=config.sso_discovery_url,
111
+ SSO_CLIENT_ID=config.sso_client_id,
112
+ SSO_CLIENT_SECRET=config.sso_client_secret,
113
+ SSO_REDIRECT_URI=config.sso_redirect_uri,
114
+ SSO_PROVIDER_NAME=config.sso_provider_name,
115
+ RATE_LIMIT_ENABLED=config.rate_limit_enabled,
116
+ OPEN_REGISTRATION=config.open_registration,
117
+ INVITE_CODE=invite_code,
118
+ INVITE_COOKIE_SECRET=invite_cookie_secret,
119
+ INVITE_GATE_ENABLED=config.invite_gate_enabled,
120
+ SECURE_COOKIES=secure_cookies,
121
+ )
122
+
123
+
124
+ __all__ = ["SecurityRuntime", "build_security_runtime"]
@@ -0,0 +1,128 @@
1
+ """SSO / OIDC config + discovery seam extracted from the app factory.
2
+
3
+ Owns the whole SSO surface that used to be inline in ``app_factory._build``:
4
+ the env-default resolution, the ``sso_config.json`` load/save, the public
5
+ (secret-stripped) view, and the OIDC discovery document cache.
6
+
7
+ Unlike the previous split (config inline in the factory + a separate discovery
8
+ cache in ``sso_runtime``), the discovery cache now lives in the *same* closure
9
+ as ``save_sso_config``, so saving a new SSO config actually invalidates the
10
+ cached discovery document. The old inline ``save_sso_config`` reassigned a
11
+ factory-local cache variable that ``_get_sso_discovery`` never read, so the
12
+ invalidation was a silent no-op — this consolidation fixes that.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+
22
+ def build_sso_config_runtime(
23
+ *,
24
+ sso_file: Path,
25
+ discovery_url: str,
26
+ client_id: str,
27
+ client_secret: str,
28
+ redirect_uri: str,
29
+ provider_name: str,
30
+ logging: Any,
31
+ ) -> Dict[str, Any]:
32
+ """Return the SSO config helpers + discovery accessor as a name → value dict."""
33
+
34
+ # Single shared discovery cache: save_sso_config and _get_sso_discovery both
35
+ # close over it, so a config change is observed on the next discovery fetch.
36
+ _discovery_cache: Dict[str, Any] = {"data": None, "url": ""}
37
+ _sso_states: Dict[str, float] = {}
38
+
39
+ def _sso_env_defaults() -> Dict[str, object]:
40
+ return {
41
+ "enabled": bool(discovery_url and client_id and client_secret),
42
+ "provider_name": provider_name,
43
+ "discovery_url": discovery_url,
44
+ "client_id": client_id,
45
+ "client_secret": client_secret,
46
+ "redirect_uri": redirect_uri,
47
+ "scopes": "openid email profile",
48
+ }
49
+
50
+ def load_sso_config() -> Dict[str, object]:
51
+ config = _sso_env_defaults()
52
+ if sso_file.exists():
53
+ try:
54
+ data = json.loads(sso_file.read_text(encoding="utf-8"))
55
+ if isinstance(data, dict):
56
+ config.update({k: v for k, v in data.items() if v is not None})
57
+ except Exception as e:
58
+ logging.warning("load_sso_config failed (using env/defaults): %s", e)
59
+ config["provider_name"] = str(config.get("provider_name") or "SSO")
60
+ config["discovery_url"] = str(config.get("discovery_url") or "")
61
+ config["client_id"] = str(config.get("client_id") or "")
62
+ config["client_secret"] = str(config.get("client_secret") or "")
63
+ config["redirect_uri"] = str(config.get("redirect_uri") or redirect_uri)
64
+ config["scopes"] = str(config.get("scopes") or "openid email profile")
65
+ config["enabled"] = bool(config.get("enabled")) and bool(
66
+ config["discovery_url"] and config["client_id"] and config["client_secret"]
67
+ )
68
+ return config
69
+
70
+ def get_sso_settings() -> Dict[str, object]:
71
+ return load_sso_config()
72
+
73
+ def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
74
+ cfg = config or get_sso_settings()
75
+ return {
76
+ "enabled": bool(cfg.get("enabled")),
77
+ "provider_name": cfg.get("provider_name") or "",
78
+ "discovery_url": cfg.get("discovery_url") or "",
79
+ "client_id": cfg.get("client_id") or "",
80
+ "redirect_uri": cfg.get("redirect_uri") or redirect_uri,
81
+ "scopes": cfg.get("scopes") or "openid email profile",
82
+ "secret_configured": bool(cfg.get("client_secret")),
83
+ }
84
+
85
+ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
86
+ current = load_sso_config()
87
+ if update.get("client_secret") == "":
88
+ update.pop("client_secret", None)
89
+ current.update({k: v for k, v in update.items() if v is not None})
90
+ current["enabled"] = bool(current.get("enabled")) and bool(
91
+ current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
92
+ )
93
+ sso_file.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
94
+ _discovery_cache["data"] = None
95
+ _discovery_cache["url"] = ""
96
+ return current
97
+
98
+ async def _get_sso_discovery() -> Optional[Dict]:
99
+ settings = get_sso_settings()
100
+ url = settings.get("discovery_url", "")
101
+ if _discovery_cache["data"] and _discovery_cache["url"] == url:
102
+ return _discovery_cache["data"]
103
+ if not url:
104
+ return None
105
+ try:
106
+ import httpx as _httpx
107
+ async with _httpx.AsyncClient() as c:
108
+ r = await c.get(url, timeout=10)
109
+ r.raise_for_status()
110
+ _discovery_cache["data"] = r.json()
111
+ _discovery_cache["url"] = url
112
+ except Exception as e:
113
+ logging.warning("SSO discovery failed: %s", e)
114
+ return None
115
+ return _discovery_cache["data"]
116
+
117
+ return {
118
+ "_sso_env_defaults": _sso_env_defaults,
119
+ "load_sso_config": load_sso_config,
120
+ "get_sso_settings": get_sso_settings,
121
+ "public_sso_config": public_sso_config,
122
+ "save_sso_config": save_sso_config,
123
+ "_get_sso_discovery": _get_sso_discovery,
124
+ "_sso_states": _sso_states,
125
+ }
126
+
127
+
128
+ __all__ = ["build_sso_config_runtime"]
@@ -0,0 +1,27 @@
1
+ """Typed mapping base for application assembly stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import fields
6
+ from typing import Any, Iterator, Mapping
7
+
8
+
9
+ class RuntimeStage(Mapping[str, Any]):
10
+ """Dataclass mixin that preserves the legacy mapping access during DI migration."""
11
+
12
+ def __getitem__(self, key: str) -> Any:
13
+ if key not in self:
14
+ raise KeyError(key)
15
+ return getattr(self, key)
16
+
17
+ def __iter__(self) -> Iterator[str]:
18
+ return (field.name for field in fields(self))
19
+
20
+ def __len__(self) -> int:
21
+ return len(fields(self))
22
+
23
+ def __contains__(self, key: object) -> bool:
24
+ return isinstance(key, str) and any(field.name == key for field in fields(self))
25
+
26
+
27
+ __all__ = ["RuntimeStage"]
@@ -0,0 +1,106 @@
1
+ """User profile + provider API-key helpers extracted from the app factory.
2
+
3
+ The helpers close over the user store, keyring adapter, identity migration, and
4
+ plaintext-key policy. Returning callables preserves the legacy ``server_app``
5
+ namespace while keeping ``app_factory._build`` focused on wiring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ def build_user_key_runtime(
14
+ *,
15
+ load_users: Any,
16
+ save_users: Any,
17
+ ensure_user_identity: Any,
18
+ keyring: Any,
19
+ allow_plaintext_api_keys: bool,
20
+ logging: Any,
21
+ http_exception: Any,
22
+ ) -> Dict[str, Any]:
23
+ """Return history-user and provider API-key helpers."""
24
+
25
+ def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
26
+ if not email:
27
+ return {"user_email": None, "user_nickname": nickname or None}
28
+ users = load_users()
29
+ user = users.get(email, {})
30
+ return {
31
+ "user_email": email,
32
+ "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
33
+ }
34
+
35
+ def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
36
+ if not email:
37
+ return None
38
+ keyring_key = f"{email}:{provider}"
39
+ if keyring is not None:
40
+ try:
41
+ key = keyring.get_password("LatticeAI", keyring_key)
42
+ if key:
43
+ return key.strip()
44
+ except Exception as exc:
45
+ logging.warning("keyring read failed for %s: %s", provider, exc)
46
+ users = load_users()
47
+ user = users.get(email) or {}
48
+ api_keys = user.get("api_keys") or {}
49
+ key = api_keys.get(provider)
50
+ if isinstance(key, str) and key.strip() and allow_plaintext_api_keys:
51
+ return key.strip()
52
+ return None
53
+
54
+ def set_user_api_key(email: str, provider: str, key: str) -> None:
55
+ keyring_key = f"{email}:{provider}"
56
+ if keyring is not None:
57
+ try:
58
+ keyring.set_password("LatticeAI", keyring_key, key)
59
+ users = load_users()
60
+ user = users.get(email)
61
+ if user and "api_keys" in user:
62
+ user["api_keys"].pop(provider, None)
63
+ if not user["api_keys"]:
64
+ user.pop("api_keys", None)
65
+ save_users(users)
66
+ return
67
+ except Exception as exc:
68
+ logging.warning("keyring write failed for %s: %s", provider, exc)
69
+ if not allow_plaintext_api_keys:
70
+ raise http_exception(
71
+ status_code=500,
72
+ detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
73
+ )
74
+
75
+ if not allow_plaintext_api_keys:
76
+ raise http_exception(
77
+ status_code=500,
78
+ detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
79
+ )
80
+
81
+ users = load_users()
82
+ user = users.get(email)
83
+ if not user:
84
+ user = {
85
+ "password_hash": "",
86
+ "salt": "",
87
+ "name": email,
88
+ "nickname": email,
89
+ "role": "user",
90
+ "disabled": False,
91
+ }
92
+ ensure_user_identity(email, user)
93
+ api_keys = user.get("api_keys") or {}
94
+ api_keys[provider] = key
95
+ user["api_keys"] = api_keys
96
+ users[email] = user
97
+ save_users(users)
98
+
99
+ return {
100
+ "get_history_user": get_history_user,
101
+ "get_user_api_key": get_user_api_key,
102
+ "set_user_api_key": set_user_api_key,
103
+ }
104
+
105
+
106
+ __all__ = ["build_user_key_runtime"]
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  from typing import Any, List
15
15
 
16
+ from latticeai.runtime.namespace_runtime import SERVER_APP_EXPORTS
17
+
16
18
 
17
19
  def _runtime():
18
20
  from latticeai.app_factory import get_shared_runtime
@@ -25,6 +27,8 @@ def __getattr__(name: str) -> Any:
25
27
  # Never let dunder probes (importlib, inspect, pickling) trigger the
26
28
  # full application construction.
27
29
  raise AttributeError(name)
30
+ if name not in SERVER_APP_EXPORTS:
31
+ raise AttributeError(f"module 'latticeai.server_app' has no attribute '{name}'")
28
32
  try:
29
33
  return getattr(_runtime(), name)
30
34
  except AttributeError as exc:
@@ -34,7 +38,7 @@ def __getattr__(name: str) -> Any:
34
38
 
35
39
 
36
40
  def __dir__() -> List[str]:
37
- return sorted(set(globals()) | set(vars(_runtime())))
41
+ return sorted(set(globals()) | set(SERVER_APP_EXPORTS))
38
42
 
39
43
 
40
44
  def main() -> None: