appmanager-server 0.2.0__py3-none-any.whl

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 (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
appmanager/health.py ADDED
@@ -0,0 +1,133 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from typing import List, Tuple
5
+
6
+ from werkzeug.test import Client
7
+ from werkzeug.wrappers import Response
8
+
9
+ from appmanager.admin.app_installer import load_wsgi_app_from_path, parse_manifest
10
+ from appmanager.database import db
11
+ from appmanager.models import AppHealthLog, InstalledApp
12
+ from appmanager.signals import health_check_completed, health_check_failed
13
+
14
+
15
+ def check_app_health(app_record: InstalledApp) -> AppHealthLog:
16
+ """
17
+ Evaluates health status of a single installed sub-app and logs the result.
18
+ Fires health_check_completed and health_check_failed signals.
19
+ """
20
+ target_dir = (
21
+ os.path.join(db.app.config["INSTALLED_APPS_DIR"], app_record.slug)
22
+ if hasattr(db, "app")
23
+ else None
24
+ )
25
+ if not target_dir:
26
+ from flask import current_app
27
+
28
+ target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], app_record.slug)
29
+
30
+ if not os.path.exists(target_dir):
31
+ log = AppHealthLog(
32
+ app_id=app_record.id,
33
+ status="unhealthy",
34
+ response_time_ms=0.0,
35
+ details=json.dumps(
36
+ {"error": f"App directory for '{app_record.slug}' missing on disk."}
37
+ ),
38
+ )
39
+ db.session.add(log)
40
+ db.session.commit()
41
+ try:
42
+ health_check_completed.send(
43
+ None, app_slug=app_record.slug, status="unhealthy", response_time_ms=0.0
44
+ )
45
+ health_check_failed.send(
46
+ None, app_slug=app_record.slug, status="unhealthy", details="App directory missing"
47
+ )
48
+ except Exception:
49
+ pass
50
+ return log
51
+
52
+ manifest = parse_manifest(target_dir)
53
+ health_path = manifest.get("health_check_path", "/health") if manifest else "/health"
54
+ entry_point = (
55
+ manifest.get("entry_point", app_record.entry_point) if manifest else app_record.entry_point
56
+ )
57
+
58
+ start_time = time.time()
59
+ try:
60
+ sub_app_obj = load_wsgi_app_from_path(target_dir, entry_point)
61
+ wsgi_callable = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
62
+
63
+ # Check if sub-app exports a direct get_health() function
64
+ if hasattr(sub_app_obj, "get_health") and callable(getattr(sub_app_obj, "get_health")):
65
+ res = sub_app_obj.get_health()
66
+ elapsed_ms = (time.time() - start_time) * 1000
67
+ status = res.get("status", "healthy") if isinstance(res, dict) else "healthy"
68
+ log = AppHealthLog(
69
+ app_id=app_record.id,
70
+ status=status,
71
+ response_time_ms=elapsed_ms,
72
+ details=json.dumps(res) if isinstance(res, dict) else str(res),
73
+ )
74
+ else:
75
+ # Issue internal WSGI request to health endpoint
76
+ client = Client(wsgi_callable, Response)
77
+ res = client.get(health_path)
78
+ elapsed_ms = (time.time() - start_time) * 1000
79
+
80
+ if res.status_code == 200:
81
+ status = "healthy"
82
+ try:
83
+ data = res.get_json() or res.get_data(as_text=True)
84
+ except Exception:
85
+ data = res.get_data(as_text=True)
86
+ details = json.dumps(data) if isinstance(data, (dict, list)) else str(data)
87
+ else:
88
+ status = "degraded" if res.status_code < 500 else "unhealthy"
89
+ details = json.dumps(
90
+ {"http_status": res.status_code, "body": res.get_data(as_text=True)}
91
+ )
92
+
93
+ log = AppHealthLog(
94
+ app_id=app_record.id, status=status, response_time_ms=elapsed_ms, details=details
95
+ )
96
+
97
+ except Exception as e:
98
+ elapsed_ms = (time.time() - start_time) * 1000
99
+ status = "unhealthy"
100
+ log = AppHealthLog(
101
+ app_id=app_record.id,
102
+ status="unhealthy",
103
+ response_time_ms=elapsed_ms,
104
+ details=json.dumps({"error": str(e)}),
105
+ )
106
+
107
+ db.session.add(log)
108
+ db.session.commit()
109
+
110
+ try:
111
+ health_check_completed.send(
112
+ None, app_slug=app_record.slug, status=log.status, response_time_ms=log.response_time_ms
113
+ )
114
+ if log.status in ("degraded", "unhealthy"):
115
+ health_check_failed.send(
116
+ None, app_slug=app_record.slug, status=log.status, details=log.details
117
+ )
118
+ except Exception:
119
+ pass
120
+
121
+ return log
122
+
123
+
124
+ def check_all_apps_health() -> List[Tuple[str, str, float]]:
125
+ """
126
+ Runs health checks across all active registered sub-apps.
127
+ """
128
+ apps = InstalledApp.query.filter_by(is_active=True).all()
129
+ results = []
130
+ for app_record in apps:
131
+ log = check_app_health(app_record)
132
+ results.append((app_record.slug, log.status, log.response_time_ms))
133
+ return results
appmanager/hooks.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ AppManager Hook and UI Slot Registry System.
3
+ Provides a decoupled, pluggable architecture for UI mount points and lifecycle hooks.
4
+ """
5
+
6
+ from typing import Any, Callable, Dict, List, Optional
7
+
8
+ from markupsafe import Markup
9
+
10
+
11
+ class HookRegistry:
12
+ """
13
+ Central registry managing UI slots and lifecycle hooks for AppManager and its extensions.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ # slot_name -> list of dicts: {'callback': fn, 'priority': int, 'app_slug': str}
18
+ self._slots: Dict[str, List[Dict[str, Any]]] = {}
19
+ # hook_name -> list of dicts: {'callback': fn, 'priority': int, 'app_slug': str}
20
+ self._hooks: Dict[str, List[Dict[str, Any]]] = {}
21
+
22
+ def register_slot(
23
+ self,
24
+ slot_name: str,
25
+ callback: Callable[..., Any],
26
+ priority: int = 10,
27
+ app_slug: Optional[str] = None,
28
+ ) -> None:
29
+ """
30
+ Registers a UI slot renderer.
31
+ Callback should return HTML string or Markup.
32
+ Lower priority value runs earlier.
33
+ """
34
+ if slot_name not in self._slots:
35
+ self._slots[slot_name] = []
36
+
37
+ # Check if already registered to prevent duplicates
38
+ for item in self._slots[slot_name]:
39
+ if item["callback"] == callback and item.get("app_slug") == app_slug:
40
+ item["priority"] = priority
41
+ return
42
+
43
+ self._slots[slot_name].append(
44
+ {"callback": callback, "priority": priority, "app_slug": app_slug}
45
+ )
46
+ self._slots[slot_name].sort(key=lambda x: x["priority"])
47
+
48
+ def unregister_slot(self, slot_name: str, app_slug: Optional[str] = None) -> None:
49
+ """
50
+ Unregisters all slot callbacks for a given app_slug or slot.
51
+ """
52
+ if slot_name in self._slots:
53
+ if app_slug:
54
+ self._slots[slot_name] = [
55
+ x for x in self._slots[slot_name] if x.get("app_slug") != app_slug
56
+ ]
57
+ else:
58
+ del self._slots[slot_name]
59
+
60
+ def render_slot(self, slot_name: str, *args: Any, **kwargs: Any) -> Markup:
61
+ """
62
+ Executes all registered callbacks for the slot in priority order
63
+ and concatenates the rendered outputs safely as Jinja Markup.
64
+ """
65
+ entries = self._slots.get(slot_name, [])
66
+ output_parts: List[str] = []
67
+
68
+ for entry in entries:
69
+ try:
70
+ result = entry["callback"](*args, **kwargs)
71
+ if result:
72
+ output_parts.append(str(result))
73
+ except Exception as e:
74
+ print(
75
+ f"[HOOK ERROR] Error rendering slot '{slot_name}' in app '{entry.get('app_slug')}': {e}"
76
+ )
77
+
78
+ return Markup("".join(output_parts))
79
+
80
+ def register_hook(
81
+ self,
82
+ hook_name: str,
83
+ callback: Callable[..., Any],
84
+ priority: int = 10,
85
+ app_slug: Optional[str] = None,
86
+ ) -> None:
87
+ """
88
+ Registers a lifecycle hook callback.
89
+ """
90
+ if hook_name not in self._hooks:
91
+ self._hooks[hook_name] = []
92
+
93
+ for item in self._hooks[hook_name]:
94
+ if item["callback"] == callback and item.get("app_slug") == app_slug:
95
+ item["priority"] = priority
96
+ return
97
+
98
+ self._hooks[hook_name].append(
99
+ {"callback": callback, "priority": priority, "app_slug": app_slug}
100
+ )
101
+ self._hooks[hook_name].sort(key=lambda x: x["priority"])
102
+
103
+ def unregister_hook(self, hook_name: str, app_slug: Optional[str] = None) -> None:
104
+ """
105
+ Unregisters lifecycle hook callbacks.
106
+ """
107
+ if hook_name in self._hooks:
108
+ if app_slug:
109
+ self._hooks[hook_name] = [
110
+ x for x in self._hooks[hook_name] if x.get("app_slug") != app_slug
111
+ ]
112
+ else:
113
+ del self._hooks[hook_name]
114
+
115
+ def trigger_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> List[Any]:
116
+ """
117
+ Triggers all registered callbacks for the lifecycle hook in priority order.
118
+ Returns a list of non-None return values from callbacks.
119
+ """
120
+ entries = self._hooks.get(hook_name, [])
121
+ results: List[Any] = []
122
+
123
+ for entry in entries:
124
+ try:
125
+ res = entry["callback"](*args, **kwargs)
126
+ if res is not None:
127
+ results.append(res)
128
+ except Exception as e:
129
+ print(
130
+ f"[HOOK ERROR] Error triggering hook '{hook_name}' in app '{entry.get('app_slug')}': {e}"
131
+ )
132
+
133
+ return results
134
+
135
+ def get_registered_slots(self) -> Dict[str, List[Dict[str, Any]]]:
136
+ """
137
+ Returns snapshot of registered UI slots for inspection.
138
+ """
139
+ return {
140
+ name: [
141
+ {"app_slug": item.get("app_slug"), "priority": item["priority"]} for item in items
142
+ ]
143
+ for name, items in self._slots.items()
144
+ }
145
+
146
+ def get_registered_hooks(self) -> Dict[str, List[Dict[str, Any]]]:
147
+ """
148
+ Returns snapshot of registered lifecycle hooks for inspection.
149
+ """
150
+ return {
151
+ name: [
152
+ {"app_slug": item.get("app_slug"), "priority": item["priority"]} for item in items
153
+ ]
154
+ for name, items in self._hooks.items()
155
+ }
156
+
157
+ def clear(self) -> None:
158
+ """
159
+ Clears all registered slots and hooks.
160
+ """
161
+ self._slots.clear()
162
+ self._hooks.clear()
163
+
164
+
165
+ # Global singleton instance
166
+ hooks = HookRegistry()
167
+
168
+
169
+ # Public helper functions
170
+ def register_slot(
171
+ slot_name: str, callback: Callable[..., Any], priority: int = 10, app_slug: Optional[str] = None
172
+ ) -> None:
173
+ hooks.register_slot(slot_name, callback, priority, app_slug)
174
+
175
+
176
+ def render_slot(slot_name: str, *args: Any, **kwargs: Any) -> Markup:
177
+ return hooks.render_slot(slot_name, *args, **kwargs)
178
+
179
+
180
+ def register_hook(
181
+ hook_name: str, callback: Callable[..., Any], priority: int = 10, app_slug: Optional[str] = None
182
+ ) -> None:
183
+ hooks.register_hook(hook_name, callback, priority, app_slug)
184
+
185
+
186
+ def trigger_hook(hook_name: str, *args: Any, **kwargs: Any) -> List[Any]:
187
+ return hooks.trigger_hook(hook_name, *args, **kwargs)
@@ -0,0 +1,256 @@
1
+ import os
2
+ import re
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ import jwt
6
+ from flask import Flask
7
+
8
+ from appmanager.admin.app_installer import load_wsgi_app_from_path
9
+ from appmanager.database import db
10
+ from appmanager.models import InstalledApp, User, UserAppPermission
11
+ from appmanager.signals import subapp_reloaded
12
+
13
+ VALID_SLUG_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
14
+
15
+
16
+ class DynamicAppDispatcherMiddleware:
17
+ """
18
+ WSGI middleware for dynamically dispatching requests to installed sub-apps under `/apps/<slug>/*`.
19
+ Provides namespace isolation, user header propagation, and in-memory callable caching.
20
+ """
21
+
22
+ def __init__(self, main_app: Flask) -> None:
23
+ self.main_app = main_app
24
+ self.sub_app_cache: Dict[str, Callable] = {}
25
+
26
+ def clear_cache(self, slug: Optional[str] = None) -> None:
27
+ """
28
+ Clears the in-memory cache for a single sub-app or all sub-apps.
29
+ """
30
+ if slug:
31
+ self.sub_app_cache.pop(slug, None)
32
+ else:
33
+ self.sub_app_cache.clear()
34
+
35
+ def _get_user_from_environ(
36
+ self, environ: Dict[str, Any], app_context_app: Flask
37
+ ) -> Optional[User]:
38
+ """
39
+ Extracts user and checks auth from WSGI request environ
40
+ """
41
+ token = None
42
+ cookie_header = environ.get("HTTP_COOKIE", "")
43
+ if cookie_header:
44
+ from http.cookies import SimpleCookie
45
+
46
+ cookie = SimpleCookie()
47
+ try:
48
+ cookie.load(cookie_header)
49
+ if "appmanager_jwt" in cookie:
50
+ token = cookie["appmanager_jwt"].value
51
+ except Exception:
52
+ pass
53
+
54
+ if not token:
55
+ auth_header = environ.get("HTTP_AUTHORIZATION", "")
56
+ if auth_header.startswith("Bearer "):
57
+ token = auth_header.split(" ")[1]
58
+
59
+ if not token:
60
+ return None
61
+
62
+ try:
63
+ jwt_secret = app_context_app.config["JWT_SECRET"]
64
+ payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
65
+ user_id = payload.get("user_id")
66
+ user = db.session.get(User, user_id)
67
+ if user and user.is_active:
68
+ return user
69
+ except Exception:
70
+ return None
71
+
72
+ return None
73
+
74
+ def _send_html_response(
75
+ self,
76
+ start_response: Callable,
77
+ status_code: int,
78
+ title: str,
79
+ message: str,
80
+ link_url: Optional[str] = None,
81
+ link_text: Optional[str] = None,
82
+ ):
83
+ status_names = {
84
+ 400: "Bad Request",
85
+ 401: "Unauthorized",
86
+ 403: "Forbidden",
87
+ 404: "Not Found",
88
+ 500: "Internal Server Error",
89
+ }
90
+ status = f"{status_code} " + status_names.get(status_code, "Error")
91
+ headers = [
92
+ ("Content-Type", "text/html; charset=utf-8"),
93
+ ("X-Content-Type-Options", "nosniff"),
94
+ ("X-Frame-Options", "SAMEORIGIN"),
95
+ ]
96
+ code_color = (
97
+ "#6366f1"
98
+ if status_code == 401
99
+ else "#ef4444"
100
+ if status_code in (403, 404)
101
+ else "#f59e0b"
102
+ )
103
+ link_html = (
104
+ f'<p><a href="{link_url}" style="background-color:#6366f1;color:white;padding:10px 20px;text-decoration:none;border-radius:8px;font-weight:500;display:inline-block;transition:all 0.2s;">{link_text}</a></p>'
105
+ if link_url
106
+ else ""
107
+ )
108
+ html = f"""<!DOCTYPE html>
109
+ <html>
110
+ <head>
111
+ <title>{title} - AppManager</title>
112
+ <meta name="viewport" content="width=device-width, initial-scale=1">
113
+ <link rel="preconnect" href="https://fonts.googleapis.com">
114
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
115
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
116
+ <style>
117
+ body {{ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box; }}
118
+ .card {{ background: #1e293b; padding: 40px; border-radius: 16px; max-width: 480px; width: 100%; text-align: center; border: 1px solid #334155; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5); }}
119
+ .code {{ font-size: 64px; font-weight: 800; color: {code_color}; margin-bottom: 8px; line-height: 1; }}
120
+ h1 {{ color: #f8fafc; font-size: 22px; font-weight: 700; margin-top: 0; margin-bottom: 12px; }}
121
+ p {{ color: #94a3b8; line-height: 1.6; font-size: 14px; margin-bottom: 24px; }}
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <div class="card">
126
+ <div class="code">{status_code}</div>
127
+ <h1>{title}</h1>
128
+ <p>{message}</p>
129
+ {link_html}
130
+ </div>
131
+ </body>
132
+ </html>"""
133
+ start_response(status, headers)
134
+ return [html.encode("utf-8")]
135
+
136
+ def __call__(self, environ: Dict[str, Any], start_response: Callable):
137
+ # 1. Strip any client-supplied spoofed AppManager internal headers from incoming environ
138
+ for header_key in [k for k in environ.keys() if k.startswith("HTTP_X_APPMANAGER_")]:
139
+ del environ[header_key]
140
+
141
+ path_info = environ.get("PATH_INFO", "")
142
+
143
+ # Check if the request is targeting an installed sub-app (/apps/<slug>/...)
144
+ if path_info.startswith("/apps/"):
145
+ parts = path_info.strip("/").split("/")
146
+ if len(parts) >= 2:
147
+ slug = parts[1]
148
+
149
+ # Validate slug to prevent path traversal
150
+ if not VALID_SLUG_PATTERN.match(slug):
151
+ return self._send_html_response(
152
+ start_response,
153
+ 400,
154
+ "Invalid App Identifier",
155
+ "The requested app path contains invalid characters.",
156
+ link_url="/",
157
+ link_text="Return to Home",
158
+ )
159
+
160
+ # Use main app context to query database
161
+ with self.main_app.app_context():
162
+ app_record = InstalledApp.query.filter_by(slug=slug, is_active=True).first()
163
+ if not app_record:
164
+ return self._send_html_response(
165
+ start_response,
166
+ 404,
167
+ "App Not Found",
168
+ f"No active Flask app is installed at path '/apps/{slug}'.",
169
+ link_url="/",
170
+ link_text="Return to Home",
171
+ )
172
+
173
+ # 1. Authenticate User & Check Permission
174
+ user = self._get_user_from_environ(environ, self.main_app)
175
+ if app_record.requires_auth:
176
+ if not user:
177
+ redirect_path = f"/auth/login?next=/apps/{slug}"
178
+ return self._send_html_response(
179
+ start_response,
180
+ 401,
181
+ "Authentication Required",
182
+ f"You must be logged into AppManager to access '{app_record.name}'.",
183
+ link_url=redirect_path,
184
+ link_text="Log In to Continue",
185
+ )
186
+
187
+ if not user.is_admin():
188
+ perm = UserAppPermission.query.filter_by(
189
+ user_id=user.id, app_id=app_record.id
190
+ ).first()
191
+ if not perm or not perm.can_access:
192
+ return self._send_html_response(
193
+ start_response,
194
+ 403,
195
+ "Access Denied",
196
+ f"You do not have permission to access the '{app_record.name}' application. Contact your administrator.",
197
+ link_url="/auth/profile",
198
+ link_text="Go to My Profile",
199
+ )
200
+
201
+ # 2. Inject Verified User & Sub-App Context Headers (Guaranteed non-spoofed)
202
+ if user:
203
+ environ["HTTP_X_APPMANAGER_USER_ID"] = str(user.id)
204
+ environ["HTTP_X_APPMANAGER_USER_EMAIL"] = user.email
205
+ environ["HTTP_X_APPMANAGER_USER_ROLE"] = user.role
206
+ environ["HTTP_X_APPMANAGER_SUBAPP_SLUG"] = slug
207
+ environ["HTTP_X_FORWARDED_PREFIX"] = f"/apps/{slug}"
208
+
209
+ # 3. Load or Retrieve Sub-App WSGI Instance
210
+ app_dir = os.path.join(
211
+ self.main_app.config["INSTALLED_APPS_DIR"], app_record.slug
212
+ )
213
+ if not os.path.exists(app_dir):
214
+ return self._send_html_response(
215
+ start_response,
216
+ 500,
217
+ "Installation Error",
218
+ f"App directory for '{app_record.name}' was not found on the server filesystem.",
219
+ link_url="/",
220
+ link_text="Return to Home",
221
+ )
222
+
223
+ try:
224
+ if slug not in self.sub_app_cache:
225
+ sub_app_obj = load_wsgi_app_from_path(app_dir, app_record.entry_point)
226
+ # Get standard wsgi_app callable if it's a Flask instance
227
+ wsgi_callable = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
228
+ self.sub_app_cache[slug] = wsgi_callable
229
+ try:
230
+ subapp_reloaded.send(self, slug=slug)
231
+ except Exception:
232
+ pass
233
+
234
+ wsgi_callable = self.sub_app_cache[slug]
235
+ except Exception as e:
236
+ return self._send_html_response(
237
+ start_response,
238
+ 500,
239
+ "App Load Error",
240
+ f"Failed to load Flask sub-app '{app_record.name}': {str(e)}",
241
+ link_url="/",
242
+ link_text="Return to Home",
243
+ )
244
+
245
+ # Rewrite SCRIPT_NAME and PATH_INFO for WSGI sub-app routing
246
+ prefix = f"/apps/{slug}"
247
+ environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + prefix
248
+ new_path_info = path_info[len(prefix) :]
249
+ if not new_path_info:
250
+ new_path_info = "/"
251
+ environ["PATH_INFO"] = new_path_info
252
+
253
+ return wsgi_callable(environ, start_response)
254
+
255
+ # Fall through to master AppManager Flask app
256
+ return self.main_app(environ, start_response)