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.
- package/README.md +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +46 -0
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +355 -952
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +250 -0
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +211 -40
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +70 -29
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +5 -14
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +2 -21
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +268 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +7 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1304
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- 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
|
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -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": "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
95
|
+
user, _ = require_admin(request)
|
|
94
96
|
try:
|
|
95
97
|
result = registry.remove(agent_id)
|
|
96
98
|
except KeyError as exc:
|
package/latticeai/api/agents.py
CHANGED
|
@@ -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=
|
|
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=
|
|
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
|
|
221
|
+
roles=_memory_grounded_roles(req.roles),
|
|
203
222
|
inputs=req.inputs,
|
|
204
223
|
max_retries=req.max_retries,
|
|
205
224
|
)
|
package/latticeai/api/auth.py
CHANGED
|
@@ -5,7 +5,8 @@ import hashlib
|
|
|
5
5
|
import logging
|
|
6
6
|
import secrets
|
|
7
7
|
import time
|
|
8
|
-
from
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
215
|
+
if entry is None or time.time() - entry.issued_at > 300:
|
|
172
216
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
173
|
-
|
|
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(
|
|
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(
|
|
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")
|